Compare commits
10 Commits
197324188c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edcd857c70 | ||
|
|
3d8d9efd70 | ||
|
|
8926de58e5 | ||
|
|
569a9e178f | ||
|
|
b833198e9d | ||
|
|
f11eb6b9ae | ||
|
|
4fa8be600c | ||
|
|
a042c348c1 | ||
|
|
cd065d07c3 | ||
|
|
8703c484e8 |
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
|
||||
@@ -1,9 +1,13 @@
|
||||
# TabataFit Environment Variables
|
||||
# Copy this file to .env and fill in your Supabase credentials
|
||||
# Copy this file to .env and fill in your credentials
|
||||
|
||||
# Supabase Configuration
|
||||
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
# RevenueCat (Apple subscriptions)
|
||||
# Defaults to test_ sandbox key if not set
|
||||
EXPO_PUBLIC_REVENUECAT_API_KEY=your_revenuecat_api_key
|
||||
|
||||
# Admin Dashboard (optional - for admin authentication)
|
||||
EXPO_PUBLIC_ADMIN_EMAIL=admin@tabatafit.app
|
||||
|
||||
227
.github/workflows/ci.yml
vendored
Normal file
227
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,227 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: TypeScript
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
lint:
|
||||
name: ESLint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: npm run test:coverage
|
||||
|
||||
- name: Run component render tests
|
||||
run: npm run test:render
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
- name: Coverage summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f coverage/coverage-summary.json ]; then
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
node -e "
|
||||
const c = require('./coverage/coverage-summary.json').total;
|
||||
const fmt = (v) => v.pct + '%';
|
||||
console.log('Statements: ' + fmt(c.statements));
|
||||
console.log('Branches: ' + fmt(c.branches));
|
||||
console.log('Functions: ' + fmt(c.functions));
|
||||
console.log('Lines: ' + fmt(c.lines));
|
||||
" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
elif [ -f coverage/coverage-final.json ]; then
|
||||
echo "Coverage report generated. Download the artifact for details." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "Coverage report not found." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Comment coverage on PR
|
||||
if: github.event_name == 'pull_request' && always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
let body = '## Test Coverage Report\n\n';
|
||||
|
||||
try {
|
||||
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
|
||||
const total = summary.total;
|
||||
const fmt = (v) => `${v.pct}%`;
|
||||
const icon = (v) => v.pct >= 80 ? '✅' : v.pct >= 60 ? '⚠️' : '❌';
|
||||
|
||||
body += '| Metric | Coverage | Status |\n';
|
||||
body += '|--------|----------|--------|\n';
|
||||
body += `| Statements | ${fmt(total.statements)} | ${icon(total.statements)} |\n`;
|
||||
body += `| Branches | ${fmt(total.branches)} | ${icon(total.branches)} |\n`;
|
||||
body += `| Functions | ${fmt(total.functions)} | ${icon(total.functions)} |\n`;
|
||||
body += `| Lines | ${fmt(total.lines)} | ${icon(total.lines)} |\n`;
|
||||
} catch (e) {
|
||||
body += '_Coverage summary not available._\n';
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const existing = comments.find(c =>
|
||||
c.user.type === 'Bot' && c.body.includes('## Test Coverage Report')
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
admin-web-test:
|
||||
name: Admin Web Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: admin-web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: admin-web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run unit tests
|
||||
run: npx vitest run
|
||||
continue-on-error: true
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run E2E tests
|
||||
run: npx playwright test
|
||||
continue-on-error: true
|
||||
|
||||
build-check:
|
||||
name: Build Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [typecheck, lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Export web build
|
||||
run: npx expo export --platform web
|
||||
continue-on-error: true
|
||||
|
||||
deploy-functions:
|
||||
name: Deploy Edge Functions
|
||||
runs-on: ubuntu-latest
|
||||
needs: [typecheck, lint, test]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to self-hosted Supabase
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.SUPABASE_DEPLOY_HOST }}
|
||||
DEPLOY_USER: ${{ secrets.SUPABASE_DEPLOY_USER }}
|
||||
DEPLOY_PATH: ${{ secrets.SUPABASE_DEPLOY_PATH }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SUPABASE_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
rsync -avz --delete \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.DS_Store' \
|
||||
-e "ssh -i ~/.ssh/deploy_key" \
|
||||
supabase/functions/ \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
|
||||
|
||||
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker restart supabase-edge-functions"
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -41,3 +41,14 @@ app-example
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# Maestro
|
||||
.maestro/screenshots/
|
||||
.maestro/recordings/
|
||||
.maestro/env.local.yaml
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Node compile cache
|
||||
node-compile-cache/
|
||||
|
||||
158
.maestro/README.md
Normal file
158
.maestro/README.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Maestro UI Testing
|
||||
|
||||
This directory contains Maestro UI tests for TabataFit.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install Maestro CLI:
|
||||
```bash
|
||||
brew tap mobile-dev-inc/tap
|
||||
brew install maestro
|
||||
```
|
||||
|
||||
2. Build and install the app on your simulator/device:
|
||||
```bash
|
||||
# iOS
|
||||
npx expo run:ios
|
||||
|
||||
# Android
|
||||
npx expo run:android
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
npm run test:maestro:all
|
||||
```
|
||||
|
||||
### Run Individual Tests
|
||||
```bash
|
||||
# Onboarding flow
|
||||
npm run test:maestro:onboarding
|
||||
|
||||
# Program browsing
|
||||
npm run test:maestro:programs
|
||||
|
||||
# Tab navigation
|
||||
npm run test:maestro:tabs
|
||||
|
||||
# Paywall/subscription
|
||||
npm run test:maestro:paywall
|
||||
|
||||
# Reset app state
|
||||
npm run test:maestro:reset
|
||||
```
|
||||
|
||||
### Run with Maestro CLI directly
|
||||
```bash
|
||||
# Run specific flow
|
||||
maestro test .maestro/flows/onboarding.yaml
|
||||
|
||||
# Run all flows
|
||||
maestro test .maestro/flows
|
||||
|
||||
# Run with device selection
|
||||
maestro test --device "iPhone 15" .maestro/flows/onboarding.yaml
|
||||
```
|
||||
|
||||
## Test Flows
|
||||
|
||||
| Flow | Description | Prerequisites |
|
||||
|------|-------------|---------------|
|
||||
| `onboarding.yaml` | Complete 6-step onboarding | Fresh install |
|
||||
| `program-browse.yaml` | Browse and select programs | Completed onboarding |
|
||||
| `tab-navigation.yaml` | Navigate between tabs | Completed onboarding |
|
||||
| `subscription.yaml` | Test paywall interactions | Fresh install |
|
||||
| `assessment.yaml` | Start assessment workout | Completed onboarding, not assessment |
|
||||
| `reset-state.yaml` | Reset app to fresh state | None |
|
||||
| `all-tests.yaml` | Run all test flows | None |
|
||||
|
||||
## Test IDs
|
||||
|
||||
Key UI elements have `testID` props for reliable element selection:
|
||||
|
||||
### Onboarding
|
||||
- `onboarding-problem-cta` - Step 1 continue button
|
||||
- `barrier-{id}` - Barrier selection cards (no-time, low-motivation, no-knowledge, no-gym)
|
||||
- `onboarding-empathy-continue` - Step 2 continue button
|
||||
- `onboarding-solution-cta` - Step 3 continue button
|
||||
- `onboarding-wow-cta` - Step 4 continue button
|
||||
- `name-input` - Name text input
|
||||
- `level-{level}` - Fitness level buttons (beginner, intermediate, advanced)
|
||||
- `goal-{goal}` - Goal buttons (weight-loss, cardio, strength, wellness)
|
||||
- `frequency-{n}x` - Frequency buttons (2x, 3x, 5x)
|
||||
- `onboarding-personalization-continue` - Step 5 continue button
|
||||
- `plan-yearly` - Annual subscription card
|
||||
- `plan-monthly` - Monthly subscription card
|
||||
- `subscribe-button` - Subscribe CTA
|
||||
- `restore-purchases` - Restore purchases link
|
||||
- `skip-paywall` - Skip paywall link
|
||||
|
||||
### Home Screen
|
||||
- `program-card-{id}` - Program cards (upper-body, lower-body, full-body)
|
||||
- `program-{id}-cta` - Program CTA buttons
|
||||
- `assessment-card` - Assessment workout card
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
1. Add `testID` prop to interactive elements in your component:
|
||||
```tsx
|
||||
<Pressable testID="my-button" onPress={handlePress}>
|
||||
<Text>Click me</Text>
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
2. Create a new YAML file in `.maestro/flows/`:
|
||||
```yaml
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: My Test
|
||||
|
||||
---
|
||||
- assertVisible: "my-button"
|
||||
- tapOn: "my-button"
|
||||
```
|
||||
|
||||
3. Add npm script to `package.json`:
|
||||
```json
|
||||
"test:maestro:mytest": "maestro test .maestro/flows/my-test.yaml"
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
For GitHub Actions, add:
|
||||
|
||||
```yaml
|
||||
- name: Run Maestro Tests
|
||||
run: |
|
||||
brew tap mobile-dev-inc/tap
|
||||
brew install maestro
|
||||
npm run test:maestro:all
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `assertVisible` to wait for elements
|
||||
- Use `optional: true` for elements that may not exist
|
||||
- Use `extendedWaitUntil` for longer timeouts
|
||||
- Use `runFlow` to compose tests from smaller flows
|
||||
- Use `env` to parameterize tests
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
# Verbose output
|
||||
maestro test --verbose .maestro/flows/onboarding.yaml
|
||||
|
||||
# Take screenshot on failure
|
||||
maestro test --screenshot .maestro/flows/onboarding.yaml
|
||||
|
||||
# Record video
|
||||
maestro record .maestro/flows/onboarding.yaml
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Maestro Documentation](https://maestro.mobile.dev/)
|
||||
- [Maestro CLI Reference](https://maestro.mobile.dev/cli)
|
||||
- [Element Selectors](https://maestro.mobile.dev/platform-support/react-native)
|
||||
17
.maestro/config.yaml
Normal file
17
.maestro/config.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Maestro Configuration for TabataFit
|
||||
# https://maestro.mobile.dev/
|
||||
|
||||
# App identifiers (iOS bundleIdentifier / Android package)
|
||||
appId: com.millianlmx.tabatafit
|
||||
|
||||
# Default flows directory
|
||||
flows:
|
||||
- .maestro/flows
|
||||
|
||||
# Global settings
|
||||
defaultTimeout: 15000
|
||||
|
||||
# Environment variables (override in .maestro/env.yaml)
|
||||
env:
|
||||
TEST_USER_NAME: Test User
|
||||
TEST_USER_EMAIL: test@example.com
|
||||
82
.maestro/flows/activity-tab.yaml
Normal file
82
.maestro/flows/activity-tab.yaml
Normal file
@@ -0,0 +1,82 @@
|
||||
# Activity Tab Flow Test
|
||||
# Tests the activity/stats dashboard screen
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Activity Tab
|
||||
|
||||
---
|
||||
# Start from home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Navigate to Activity tab
|
||||
- tapOn:
|
||||
text: "Activity"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "activity-tab"
|
||||
optional: true
|
||||
|
||||
# Verify activity screen loaded — check for stats elements
|
||||
- assertVisible:
|
||||
text: ".*Activity.*"
|
||||
timeout: 5000
|
||||
|
||||
# Check for streak display
|
||||
- assertVisible:
|
||||
text: ".*streak.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for workout count stats
|
||||
- assertVisible:
|
||||
text: ".*workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for calories display
|
||||
- assertVisible:
|
||||
text: ".*cal.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down to see weekly chart or history
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for weekly chart or activity history section
|
||||
- assertVisible:
|
||||
text: ".*week.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down further to see history
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for achievement badges if present
|
||||
- assertVisible:
|
||||
text: ".*achievement.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll back to top
|
||||
- scroll:
|
||||
direction: UP
|
||||
duration: 1000
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible:
|
||||
id: "program-card-upper-body"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
33
.maestro/flows/all-tests.yaml
Normal file
33
.maestro/flows/all-tests.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
# All Tests Suite
|
||||
# Run all test flows sequentially
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Full Test Suite
|
||||
|
||||
env:
|
||||
TEST_USER_NAME: Maestro Test User
|
||||
|
||||
---
|
||||
# Run onboarding flow
|
||||
- runFlow: ./onboarding.yaml
|
||||
|
||||
# Run program browsing
|
||||
- runFlow: ./program-browse.yaml
|
||||
|
||||
# Run tab navigation
|
||||
- runFlow: ./tab-navigation.yaml
|
||||
|
||||
# Run explore freemium (lock badges, paywall gating)
|
||||
- runFlow: ./explore-freemium.yaml
|
||||
|
||||
# Run collection detail
|
||||
- runFlow: ./collection-detail.yaml
|
||||
|
||||
# Run workout player
|
||||
- runFlow: ./workout-player.yaml
|
||||
|
||||
# Run activity tab
|
||||
- runFlow: ./activity-tab.yaml
|
||||
|
||||
# Run profile & settings
|
||||
- runFlow: ./profile-settings.yaml
|
||||
16
.maestro/flows/assessment.yaml
Normal file
16
.maestro/flows/assessment.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
# Assessment Flow Test
|
||||
# Tests starting the assessment workout from home screen
|
||||
# Prerequisite: User must have completed onboarding but not assessment
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Assessment Flow
|
||||
|
||||
---
|
||||
# Look for assessment card (only visible if not completed)
|
||||
- assertVisible: "assessment-card"
|
||||
- tapOn: "assessment-card"
|
||||
|
||||
# Verify we're on assessment screen
|
||||
- assertVisible:
|
||||
text: ".*Assessment.*"
|
||||
timeout: 5000
|
||||
93
.maestro/flows/collection-detail.yaml
Normal file
93
.maestro/flows/collection-detail.yaml
Normal file
@@ -0,0 +1,93 @@
|
||||
# Collection Detail Test
|
||||
# Tests navigating to a collection and viewing its workouts
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Collection Detail
|
||||
|
||||
---
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
|
||||
# Verify collections section
|
||||
- assertVisible:
|
||||
id: "collections-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Tap the first collection card
|
||||
- tapOn:
|
||||
text: ".*collection.*"
|
||||
optional: true
|
||||
|
||||
# If collection-card testIDs are visible, tap by testID instead
|
||||
- tapOn:
|
||||
id: "collection-card-.*"
|
||||
optional: true
|
||||
|
||||
# Verify collection detail screen loaded
|
||||
- assertVisible:
|
||||
id: "collection-detail-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Verify hero card is visible
|
||||
- assertVisible:
|
||||
id: "collection-hero"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify back button exists
|
||||
- assertVisible:
|
||||
id: "collection-back-button"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify workouts are listed
|
||||
- assertVisible:
|
||||
text: ".*Workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll to see more workouts
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Tap a workout in the collection
|
||||
- tapOn:
|
||||
id: "collection-workout-.*"
|
||||
optional: true
|
||||
|
||||
# Verify workout detail opened
|
||||
- assertVisible:
|
||||
id: "workout-detail-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back to collection
|
||||
- pressKey: back
|
||||
optional: true
|
||||
|
||||
# Go back to explore via back button
|
||||
- tapOn:
|
||||
id: "collection-back-button"
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
106
.maestro/flows/explore-freemium.yaml
Normal file
106
.maestro/flows/explore-freemium.yaml
Normal file
@@ -0,0 +1,106 @@
|
||||
# Explore Tab Freemium Test
|
||||
# Tests lock badges on non-free workouts, free workout access,
|
||||
# and paywall gating for locked workouts.
|
||||
# Prerequisite: User must have completed onboarding (free user, not premium)
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Explore Freemium
|
||||
|
||||
---
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
|
||||
# Verify collections section is visible
|
||||
- assertVisible:
|
||||
id: "collections-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify featured section is visible
|
||||
- assertVisible:
|
||||
id: "featured-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Verify filters section is visible
|
||||
- assertVisible:
|
||||
id: "filters-section"
|
||||
timeout: 3000
|
||||
|
||||
# Scroll down to see workout cards
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Tap a free workout (ID 1 — Full Body Ignite) — should go to detail, not paywall
|
||||
- tapOn:
|
||||
id: "workout-card-1"
|
||||
optional: true
|
||||
|
||||
# On workout detail: verify start button (not unlock)
|
||||
- assertVisible:
|
||||
id: "workout-start-button"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Verify video preview is rendered
|
||||
- assertVisible:
|
||||
id: "workout-video-preview"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Go back to explore
|
||||
- pressKey: back
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
|
||||
# Scroll to find a locked workout
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 800
|
||||
|
||||
# Tap a locked workout (ID 2 — not in free tier)
|
||||
- tapOn:
|
||||
id: "workout-card-2"
|
||||
optional: true
|
||||
|
||||
# On workout detail: verify unlock/locked button
|
||||
- assertVisible:
|
||||
id: "workout-unlock-button"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Tap unlock button — should navigate to paywall
|
||||
- tapOn:
|
||||
id: "workout-unlock-button"
|
||||
optional: true
|
||||
|
||||
# Verify paywall screen appeared
|
||||
- assertVisible:
|
||||
text: ".*Premium.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back from paywall
|
||||
- pressKey: back
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
46
.maestro/flows/onboarding.yaml
Normal file
46
.maestro/flows/onboarding.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# Onboarding Flow Test
|
||||
# Tests the complete 6-step onboarding process
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Onboarding Flow
|
||||
|
||||
---
|
||||
- launchApp
|
||||
# Step 1: Problem Screen
|
||||
- assertVisible: "onboarding-problem-cta"
|
||||
- tapOn: "onboarding-problem-cta"
|
||||
|
||||
# Step 2: Empathy Screen - Select barriers
|
||||
- assertVisible: "barrier-no-time"
|
||||
- tapOn: "barrier-no-time"
|
||||
- tapOn: "barrier-low-motivation"
|
||||
- assertVisible: "onboarding-empathy-continue"
|
||||
- tapOn: "onboarding-empathy-continue"
|
||||
|
||||
# Step 3: Solution Screen
|
||||
- assertVisible: "onboarding-solution-cta"
|
||||
- tapOn: "onboarding-solution-cta"
|
||||
|
||||
# Step 4: Wow Screen (features reveal)
|
||||
- assertVisible: "onboarding-wow-cta"
|
||||
- tapOn: "onboarding-wow-cta"
|
||||
|
||||
# Step 5: Personalization
|
||||
- assertVisible: "name-input"
|
||||
- tapOn: "name-input"
|
||||
- inputText: "Test User"
|
||||
|
||||
- tapOn: "level-intermediate"
|
||||
- tapOn: "goal-strength"
|
||||
- tapOn: "frequency-3x"
|
||||
|
||||
- assertVisible: "onboarding-personalization-continue"
|
||||
- tapOn: "onboarding-personalization-continue"
|
||||
|
||||
# Step 6: Paywall - Skip subscription
|
||||
- assertVisible: "subscribe-button"
|
||||
- assertVisible: "skip-paywall"
|
||||
- tapOn: "skip-paywall"
|
||||
|
||||
# Verify we're on the home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
119
.maestro/flows/profile-settings.yaml
Normal file
119
.maestro/flows/profile-settings.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
# Profile & Settings Flow Test
|
||||
# Tests the profile screen, settings toggles, and navigation
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Profile Settings
|
||||
|
||||
---
|
||||
# Start from home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Navigate to Profile tab
|
||||
- tapOn:
|
||||
text: "Profile"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "profile-tab"
|
||||
optional: true
|
||||
|
||||
# Verify profile screen loaded
|
||||
- assertVisible:
|
||||
text: ".*Profile.*"
|
||||
timeout: 5000
|
||||
|
||||
# Check user avatar/name is displayed
|
||||
- assertVisible:
|
||||
text: ".*Test User.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check stats section — real activity store data (may show 0 if no workouts done)
|
||||
- assertVisible:
|
||||
text: ".*workout.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: ".*min.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
text: ".*cal.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll to settings section
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for Haptic Feedback toggle
|
||||
- assertVisible:
|
||||
text: ".*aptic.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for Sound Effects toggle
|
||||
- assertVisible:
|
||||
text: ".*ound.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for Voice Coaching toggle
|
||||
- assertVisible:
|
||||
text: ".*oice.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down to notifications section
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for Reminders toggle
|
||||
- assertVisible:
|
||||
text: ".*eminder.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll down to support section
|
||||
- scroll:
|
||||
direction: DOWN
|
||||
duration: 500
|
||||
|
||||
# Check for Rate App option
|
||||
- assertVisible:
|
||||
text: ".*Rate.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for Contact Us option
|
||||
- assertVisible:
|
||||
text: ".*Contact.*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Check for app version
|
||||
- assertVisible:
|
||||
text: ".*1\\..*"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Scroll back to top
|
||||
- scroll:
|
||||
direction: UP
|
||||
duration: 1500
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible:
|
||||
id: "program-card-upper-body"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
42
.maestro/flows/program-browse.yaml
Normal file
42
.maestro/flows/program-browse.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Program Browsing Test
|
||||
# Tests navigation through programs from home screen
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Program Browsing
|
||||
|
||||
---
|
||||
# Verify home screen loaded
|
||||
- assertVisible: "program-card-upper-body"
|
||||
- assertVisible: "program-card-lower-body"
|
||||
- assertVisible: "program-card-full-body"
|
||||
|
||||
# Tap Upper Body program
|
||||
- tapOn: "program-upper-body-cta"
|
||||
|
||||
# Wait for program detail screen
|
||||
- assertVisible:
|
||||
text: ".*Upper Body.*"
|
||||
timeout: 5000
|
||||
|
||||
# Navigate back
|
||||
- back
|
||||
|
||||
# Tap Lower Body program
|
||||
- assertVisible: "program-card-lower-body"
|
||||
- tapOn: "program-lower-body-cta"
|
||||
- assertVisible:
|
||||
text: ".*Lower Body.*"
|
||||
timeout: 5000
|
||||
- back
|
||||
|
||||
# Tap Full Body program
|
||||
- assertVisible: "program-card-full-body"
|
||||
- tapOn: "program-full-body-cta"
|
||||
- assertVisible:
|
||||
text: ".*Full Body.*"
|
||||
timeout: 5000
|
||||
- back
|
||||
|
||||
# Verify we're back on home
|
||||
- assertVisible: "program-card-upper-body"
|
||||
17
.maestro/flows/reset-state.yaml
Normal file
17
.maestro/flows/reset-state.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Reset App State Helper
|
||||
# Use this to reset the app to a fresh state for testing
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Reset App State
|
||||
|
||||
---
|
||||
# Kill the app
|
||||
- killApp
|
||||
|
||||
# Clear app data (iOS Simulator)
|
||||
# Note: On Android, use: adb shell pm clear com.millianlmx.tabatafit
|
||||
- launchApp:
|
||||
clearState: true
|
||||
|
||||
# App should start at onboarding
|
||||
- assertVisible: "onboarding-problem-cta"
|
||||
38
.maestro/flows/subscription.yaml
Normal file
38
.maestro/flows/subscription.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Subscription Paywall Test
|
||||
# Tests the paywall subscription flow
|
||||
# This test requires a fresh install (onboarding not completed)
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Subscription Paywall
|
||||
|
||||
---
|
||||
# Navigate through onboarding to paywall (steps 1-5)
|
||||
- tapOn: "onboarding-problem-cta"
|
||||
- tapOn: "barrier-no-time"
|
||||
- tapOn: "onboarding-empathy-continue"
|
||||
- tapOn: "onboarding-solution-cta"
|
||||
- tapOn: "onboarding-wow-cta"
|
||||
|
||||
# Enter name to enable continue
|
||||
- tapOn: "name-input"
|
||||
- inputText: "Premium User"
|
||||
- tapOn: "onboarding-personalization-continue"
|
||||
|
||||
# On paywall screen
|
||||
- assertVisible: "plan-yearly"
|
||||
- assertVisible: "plan-monthly"
|
||||
- assertVisible: "subscribe-button"
|
||||
- assertVisible: "skip-paywall"
|
||||
|
||||
# Test plan selection
|
||||
- tapOn: "plan-monthly"
|
||||
- assertVisible: "subscribe-button"
|
||||
|
||||
# Test restore purchases
|
||||
- tapOn: "restore-purchases"
|
||||
|
||||
# Skip paywall
|
||||
- tapOn: "skip-paywall"
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
55
.maestro/flows/tab-navigation.yaml
Normal file
55
.maestro/flows/tab-navigation.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
# Tab Navigation Test
|
||||
# Tests switching between all tabs in the app
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Tab Navigation
|
||||
|
||||
---
|
||||
# Start on home tab
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Navigate to Explore tab
|
||||
- tapOn:
|
||||
text: "Explore"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "explore-tab"
|
||||
optional: true
|
||||
|
||||
# Verify Explore screen loaded with key sections
|
||||
- assertVisible:
|
||||
id: "explore-screen"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
- assertVisible:
|
||||
id: "filters-section"
|
||||
timeout: 3000
|
||||
optional: true
|
||||
|
||||
# Navigate to Activity tab
|
||||
- tapOn:
|
||||
text: "Activity"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "activity-tab"
|
||||
optional: true
|
||||
|
||||
# Navigate to Profile tab
|
||||
- tapOn:
|
||||
text: "Profile"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "profile-tab"
|
||||
optional: true
|
||||
|
||||
# Navigate back to Home
|
||||
- tapOn:
|
||||
text: "Home"
|
||||
optional: true
|
||||
- tapOn:
|
||||
id: "home-tab"
|
||||
optional: true
|
||||
|
||||
# Verify home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
102
.maestro/flows/workout-player.yaml
Normal file
102
.maestro/flows/workout-player.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
# Workout Player Flow Test
|
||||
# Tests starting a workout, timer controls, and completion
|
||||
# Prerequisite: User must have completed onboarding
|
||||
|
||||
appId: com.millianlmx.tabatafit
|
||||
name: Workout Player
|
||||
|
||||
---
|
||||
# Start from home screen
|
||||
- assertVisible: "program-card-upper-body"
|
||||
|
||||
# Open the Upper Body program
|
||||
- tapOn: "program-upper-body-cta"
|
||||
|
||||
# Wait for program detail screen to load
|
||||
- assertVisible:
|
||||
text: ".*Upper Body.*"
|
||||
timeout: 5000
|
||||
|
||||
# Tap on first workout in the program
|
||||
- tapOn:
|
||||
text: ".*Start.*"
|
||||
index: 0
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: ".*Begin.*"
|
||||
index: 0
|
||||
optional: true
|
||||
|
||||
# Wait for player screen to load — look for the play button
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: ".*PREP.*"
|
||||
timeout: 10000
|
||||
optional: true
|
||||
|
||||
# If no PREP text, look for the play icon or workout title
|
||||
- assertVisible:
|
||||
text: ".*Workout.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Start the workout — tap the play button (center of screen)
|
||||
- tapOn:
|
||||
point: "50%,50%"
|
||||
|
||||
# Wait for timer to start — PREP phase should appear
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: ".*PREP.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Wait a few seconds for the timer to tick
|
||||
- swipe:
|
||||
direction: UP
|
||||
duration: 100
|
||||
optional: true
|
||||
|
||||
# Verify timer is running — time display should be visible
|
||||
- assertVisible:
|
||||
text: ".*:.*"
|
||||
timeout: 5000
|
||||
|
||||
# Test pause — tap the pause button (center area)
|
||||
- tapOn:
|
||||
point: "50%,80%"
|
||||
optional: true
|
||||
|
||||
# Wait briefly
|
||||
- swipe:
|
||||
direction: UP
|
||||
duration: 100
|
||||
optional: true
|
||||
|
||||
# Resume — tap again
|
||||
- tapOn:
|
||||
point: "50%,80%"
|
||||
optional: true
|
||||
|
||||
# Close the player — look for close/stop button (top-left area)
|
||||
- tapOn:
|
||||
point: "10%,8%"
|
||||
optional: true
|
||||
|
||||
# If close button was in a different location, try the stop button
|
||||
- tapOn:
|
||||
text: ".*close.*"
|
||||
optional: true
|
||||
|
||||
# Verify we're back on the program screen or home
|
||||
- assertVisible:
|
||||
text: ".*Upper Body.*"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
|
||||
# Go back to home
|
||||
- back
|
||||
- assertVisible:
|
||||
id: "program-card-upper-body"
|
||||
timeout: 5000
|
||||
optional: true
|
||||
87
AGENTS.md
87
AGENTS.md
@@ -305,13 +305,39 @@ const queryClient = new QueryClient({
|
||||
# TypeScript
|
||||
npx tsc --noEmit
|
||||
|
||||
# Run tests
|
||||
# Run unit tests (Vitest)
|
||||
npm test
|
||||
|
||||
# Run tests in watch mode
|
||||
npm run test:watch
|
||||
|
||||
# Run tests with coverage report
|
||||
npm run test:coverage
|
||||
|
||||
# Run Maestro E2E tests
|
||||
npm run test:maestro
|
||||
|
||||
# Lint
|
||||
npx eslint .
|
||||
```
|
||||
|
||||
#### Test Structure
|
||||
```
|
||||
src/__tests__/
|
||||
setup.ts # Mocks and test configuration
|
||||
stores/ # Zustand store tests
|
||||
hooks/ # React hooks tests
|
||||
services/ # Service layer tests
|
||||
components/ # Component logic tests
|
||||
data/ # Data validation tests
|
||||
```
|
||||
|
||||
#### Coverage Goals
|
||||
- **Stores**: 80%+ (business logic)
|
||||
- **Services**: 80%+ (API integration)
|
||||
- **Hooks**: 70%+ (timer, purchases)
|
||||
- **Components**: 50%+ (critical UI)
|
||||
|
||||
### Key Takeaways
|
||||
|
||||
1. **Start simple**: Always test in Expo Go before creating custom builds
|
||||
@@ -360,3 +386,62 @@ COMPLETE: '#30D158' // Green
|
||||
---
|
||||
|
||||
*Last updated: March 14, 2026*
|
||||
|
||||
# context-mode — MANDATORY routing rules
|
||||
|
||||
You have context-mode MCP tools available. These rules are NOT optional — they protect your context window from flooding. A single unrouted command can dump 56 KB into context and waste the entire session.
|
||||
|
||||
## BLOCKED commands — do NOT attempt these
|
||||
|
||||
### curl / wget — BLOCKED
|
||||
Any shell command containing `curl` or `wget` will be intercepted and blocked by the context-mode plugin. Do NOT retry.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` to fetch and index web pages
|
||||
- `context-mode_ctx_execute(language: "javascript", code: "const r = await fetch(...)")` to run HTTP calls in sandbox
|
||||
|
||||
### Inline HTTP — BLOCKED
|
||||
Any shell command containing `fetch('http`, `requests.get(`, `requests.post(`, `http.get(`, or `http.request(` will be intercepted and blocked. Do NOT retry with shell.
|
||||
Instead use:
|
||||
- `context-mode_ctx_execute(language, code)` to run HTTP calls in sandbox — only stdout enters context
|
||||
|
||||
### Direct web fetching — BLOCKED
|
||||
Do NOT use any direct URL fetching tool. Use the sandbox equivalent.
|
||||
Instead use:
|
||||
- `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` to query the indexed content
|
||||
|
||||
## REDIRECTED tools — use sandbox equivalents
|
||||
|
||||
### Shell (>20 lines output)
|
||||
Shell is ONLY for: `git`, `mkdir`, `rm`, `mv`, `cd`, `ls`, `npm install`, `pip install`, and other short-output commands.
|
||||
For everything else, use:
|
||||
- `context-mode_ctx_batch_execute(commands, queries)` — run multiple commands + search in ONE call
|
||||
- `context-mode_ctx_execute(language: "shell", code: "...")` — run in sandbox, only stdout enters context
|
||||
|
||||
### File reading (for analysis)
|
||||
If you are reading a file to **edit** it → reading is correct (edit needs content in context).
|
||||
If you are reading to **analyze, explore, or summarize** → use `context-mode_ctx_execute_file(path, language, code)` instead. Only your printed summary enters context.
|
||||
|
||||
### grep / search (large results)
|
||||
Search results can flood context. Use `context-mode_ctx_execute(language: "shell", code: "grep ...")` to run searches in sandbox. Only your printed summary enters context.
|
||||
|
||||
## Tool selection hierarchy
|
||||
|
||||
1. **GATHER**: `context-mode_ctx_batch_execute(commands, queries)` — Primary tool. Runs all commands, auto-indexes output, returns search results. ONE call replaces 30+ individual calls.
|
||||
2. **FOLLOW-UP**: `context-mode_ctx_search(queries: ["q1", "q2", ...])` — Query indexed content. Pass ALL questions as array in ONE call.
|
||||
3. **PROCESSING**: `context-mode_ctx_execute(language, code)` | `context-mode_ctx_execute_file(path, language, code)` — Sandbox execution. Only stdout enters context.
|
||||
4. **WEB**: `context-mode_ctx_fetch_and_index(url, source)` then `context-mode_ctx_search(queries)` — Fetch, chunk, index, query. Raw HTML never enters context.
|
||||
5. **INDEX**: `context-mode_ctx_index(content, source)` — Store content in FTS5 knowledge base for later search.
|
||||
|
||||
## Output constraints
|
||||
|
||||
- Keep responses under 500 words.
|
||||
- Write artifacts (code, configs, PRDs) to FILES — never return them as inline text. Return only: file path + 1-line description.
|
||||
- When indexing content, use descriptive source labels so others can `search(source: "label")` later.
|
||||
|
||||
## ctx commands
|
||||
|
||||
| Command | Action |
|
||||
|---------|--------|
|
||||
| `ctx stats` | Call the `stats` MCP tool and display the full output verbatim |
|
||||
| `ctx doctor` | Call the `doctor` MCP tool, run the returned shell command, display as checklist |
|
||||
| `ctx upgrade` | Call the `upgrade` MCP tool, run the returned shell command, display as checklist |
|
||||
|
||||
37
README.md
37
README.md
@@ -5,6 +5,8 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Vision
|
||||
|
||||
@@ -66,6 +68,41 @@ src/
|
||||
app/ # Expo Router routes
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Unit tests with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Component render tests
|
||||
npm run test:render
|
||||
|
||||
# All unit + render tests
|
||||
npm test && npm run test:render
|
||||
|
||||
# Maestro E2E (requires Expo dev server + simulator)
|
||||
npm run test:maestro
|
||||
|
||||
# Admin-web tests
|
||||
cd admin-web && npm test # Unit tests
|
||||
cd admin-web && npm run test:e2e # Playwright E2E
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
| Layer | Target | Tests |
|
||||
|-------|--------|-------|
|
||||
| Stores | 80%+ | playerStore, activityStore, userStore, programStore |
|
||||
| Services | 80%+ | analytics, music, purchases, sync |
|
||||
| Hooks | 70%+ | useTimer, useHaptics, useAudio, usePurchases, useMusicPlayer, useNotifications, useSupabaseData |
|
||||
| Components | 50%+ | StyledText, VideoPlayer, WorkoutCard, GlassCard, CollectionCard, modals, Skeleton |
|
||||
| Data | 80%+ | achievements, collections, programs, trainers, workouts |
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- **Mobile (Maestro)**: Onboarding, tab navigation, program browse, workout player, activity, profile/settings
|
||||
- **Admin Web (Playwright)**: Auth, navigation, workouts CRUD, trainers, collections
|
||||
|
||||
## License
|
||||
|
||||
Proprietary — All rights reserved.
|
||||
|
||||
1249
admin-web/app/music/page.tsx
Normal file
1249
admin-web/app/music/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
||||
Users,
|
||||
FolderOpen,
|
||||
ImageIcon,
|
||||
Music,
|
||||
LogOut,
|
||||
Flame,
|
||||
} from "lucide-react";
|
||||
@@ -20,6 +21,7 @@ const navItems = [
|
||||
{ href: "/trainers", label: "Trainers", icon: Users },
|
||||
{ href: "/collections", label: "Collections", icon: FolderOpen },
|
||||
{ href: "/media", label: "Media", icon: ImageIcon },
|
||||
{ href: "/music", label: "Music", icon: Music },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
130
admin-web/e2e/collections.spec.ts
Normal file
130
admin-web/e2e/collections.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Collections List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/collections')
|
||||
})
|
||||
|
||||
test('should display collections page header', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { name: /collections|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Collection button', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
const addButton = page.getByRole('button', { name: /add collection/i })
|
||||
await expect(addButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display subtitle text', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await expect(page.getByText(/organize workouts into collections/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display collection cards after loading', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Should show collection cards in a grid layout
|
||||
const grid = page.locator('[class*="grid"]')
|
||||
await expect(grid).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display collection title and description on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find collection cards
|
||||
const cards = page.locator('[class*="bg-neutral-900"]').filter({ has: page.locator('h3') })
|
||||
const count = await cards.count()
|
||||
|
||||
if (count > 0) {
|
||||
const firstCard = cards.first()
|
||||
// Card should have a title (h3)
|
||||
await expect(firstCard.locator('h3')).toBeVisible()
|
||||
// Card should have description text (p element)
|
||||
const description = firstCard.locator('p').first()
|
||||
await expect(description).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display collection icon', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Icon containers have specific styling
|
||||
const iconContainers = page.locator('[class*="w-12"][class*="h-12"][class*="rounded-xl"]')
|
||||
const count = await iconContainers.count()
|
||||
|
||||
if (count > 0) {
|
||||
await expect(iconContainers.first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display gradient bars for collections that have them', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Gradient bars have inline background style with linear-gradient
|
||||
const gradientBars = page.locator('[class*="h-2"][class*="rounded-full"]')
|
||||
const count = await gradientBars.count()
|
||||
|
||||
// Gradient bars are optional (only shown if collection has gradient property)
|
||||
if (count > 0) {
|
||||
const firstBar = gradientBars.first()
|
||||
const style = await firstBar.getAttribute('style')
|
||||
expect(style).toContain('linear-gradient')
|
||||
}
|
||||
})
|
||||
|
||||
test('should have edit and delete buttons on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const editButtons = page.locator('button').filter({ has: page.locator('svg.lucide-edit') })
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
|
||||
const editCount = await editButtons.count()
|
||||
const deleteCount = await deleteButtons.count()
|
||||
|
||||
// If collections are displayed, they should have action buttons
|
||||
if (editCount > 0) {
|
||||
expect(editCount).toBeGreaterThan(0)
|
||||
expect(deleteCount).toBeGreaterThan(0)
|
||||
// Each collection should have both edit and delete
|
||||
expect(editCount).toBe(deleteCount)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Collections Page Loading State', () => {
|
||||
test('should show loading spinner initially', async ({ page }) => {
|
||||
// Navigate and check for spinner before data loads
|
||||
await page.goto('/collections')
|
||||
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
// The spinner might be very brief, so we just verify the page loads
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// After loading, spinner should be gone
|
||||
const spinner = page.locator('[class*="animate-spin"]')
|
||||
await expect(spinner).not.toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
160
admin-web/e2e/trainers.spec.ts
Normal file
160
admin-web/e2e/trainers.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Trainers List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/trainers')
|
||||
})
|
||||
|
||||
test('should display trainers page header', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { name: /trainers|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Trainer button', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
const addButton = page.getByRole('link', { name: /add trainer/i }).or(
|
||||
page.getByRole('button', { name: /add trainer/i })
|
||||
)
|
||||
await expect(addButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display trainer cards or empty state', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Should show either trainer cards or empty state
|
||||
const hasTrainerCards = await page.locator('[class*="grid"]').locator('[class*="bg-neutral-900"]').count() > 0
|
||||
const hasEmptyState = await page.getByText(/no trainers yet/i).isVisible().catch(() => false)
|
||||
const hasError = await page.getByText(/failed to load/i).isVisible().catch(() => false)
|
||||
|
||||
expect(hasTrainerCards || hasEmptyState || hasError).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should display trainer name and specialty on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// If there are trainer cards, check they have name and specialty
|
||||
const cards = page.locator('[class*="bg-neutral-900"]').filter({ has: page.locator('h3') })
|
||||
const count = await cards.count()
|
||||
|
||||
if (count > 0) {
|
||||
const firstCard = cards.first()
|
||||
// Card should have a name (h3 element)
|
||||
await expect(firstCard.locator('h3')).toBeVisible()
|
||||
// Card should have specialty text
|
||||
const specialtyText = firstCard.locator('p').first()
|
||||
await expect(specialtyText).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should show workout count on trainer cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Check for "X workouts" text
|
||||
const workoutCountText = page.getByText(/\d+ workouts/i)
|
||||
const visible = await workoutCountText.first().isVisible().catch(() => false)
|
||||
|
||||
// Only assert if trainers exist
|
||||
if (visible) {
|
||||
await expect(workoutCountText.first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should have edit and delete action buttons on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find edit and delete buttons (icon buttons with svg)
|
||||
const editButtons = page.locator('button').filter({ has: page.locator('svg.lucide-edit') })
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
|
||||
const editCount = await editButtons.count()
|
||||
const deleteCount = await deleteButtons.count()
|
||||
|
||||
// If trainers are displayed, they should have action buttons
|
||||
if (editCount > 0) {
|
||||
expect(editCount).toBeGreaterThan(0)
|
||||
expect(deleteCount).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Trainers Delete Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/trainers')
|
||||
})
|
||||
|
||||
test('should open delete confirmation dialog', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).toBeVisible()
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^delete$/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should close delete dialog on Cancel', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).toBeVisible()
|
||||
|
||||
// Click cancel
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Trainers Error State', () => {
|
||||
test('should show error state with retry button on failure', async ({ page }) => {
|
||||
// This test verifies the error UI exists in the component
|
||||
// In actual failure scenarios, it would show the error state
|
||||
await page.goto('/trainers')
|
||||
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Check if error state is shown (only if Supabase is unreachable)
|
||||
const hasError = await page.getByText(/failed to load trainers/i).isVisible().catch(() => false)
|
||||
if (hasError) {
|
||||
await expect(page.getByRole('button', { name: /try again/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
207
admin-web/e2e/workouts.spec.ts
Normal file
207
admin-web/e2e/workouts.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Workouts List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts')
|
||||
})
|
||||
|
||||
test('should display workouts page header', async ({ page }) => {
|
||||
// May redirect to login if not authenticated
|
||||
const heading = page.getByRole('heading', { name: /workouts|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Workout button', async ({ page }) => {
|
||||
// If authenticated, should see the Add Workout button
|
||||
const addButton = page.getByRole('link', { name: /add workout/i })
|
||||
|
||||
// Page might redirect to login — check if we're on workouts page
|
||||
const url = page.url()
|
||||
if (url.includes('/workouts')) {
|
||||
await expect(addButton).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display workouts table with correct columns', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
// Wait for loading to finish (loader disappears)
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const table = page.locator('table')
|
||||
// Table may or may not be visible depending on data
|
||||
const tableVisible = await table.isVisible().catch(() => false)
|
||||
|
||||
if (tableVisible) {
|
||||
await expect(page.getByRole('columnheader', { name: /title/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /category/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /level/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /duration/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /rounds/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /actions/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should navigate to new workout page', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
const addButton = page.getByRole('link', { name: /add workout/i })
|
||||
if (await addButton.isVisible().catch(() => false)) {
|
||||
await addButton.click()
|
||||
await expect(page).toHaveURL(/.*workouts\/new/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('New Workout Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts/new')
|
||||
})
|
||||
|
||||
test('should display create workout heading', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await expect(page.getByRole('heading', { name: /create new workout/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have back to workouts link', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
const backLink = page.getByRole('link', { name: /back to workouts/i })
|
||||
await expect(backLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display workout form with tabs', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Form should have 4 tabs: Basics, Timing, Content, Media
|
||||
await expect(page.getByRole('tab', { name: /basics/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /timing/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /content/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /media/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show basics tab fields by default', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Basics tab should be active by default
|
||||
await expect(page.getByLabel(/workout title/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between form tabs', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Click Timing tab
|
||||
await page.getByRole('tab', { name: /timing/i }).click()
|
||||
await expect(page.getByLabel(/total rounds/i)).toBeVisible()
|
||||
|
||||
// Click Content tab
|
||||
await page.getByRole('tab', { name: /content/i }).click()
|
||||
await expect(page.getByText(/exercises/i).first()).toBeVisible()
|
||||
|
||||
// Click Media tab
|
||||
await page.getByRole('tab', { name: /media/i }).click()
|
||||
await expect(page.getByText(/music vibe/i).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Cancel and Create Workout buttons', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /create workout/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show validation errors on empty submit', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Clear the title field and submit
|
||||
const titleInput = page.getByLabel(/workout title/i)
|
||||
await titleInput.fill('')
|
||||
|
||||
// Click submit
|
||||
await page.getByRole('button', { name: /create workout/i }).click()
|
||||
|
||||
// Should show validation error for title
|
||||
await expect(page.getByText(/title is required/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate back when Cancel is clicked', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
await expect(page).toHaveURL(/.*workouts$/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workout Detail Page', () => {
|
||||
test('should show 404 or redirect for non-existent workout', async ({ page }) => {
|
||||
await page.goto('/workouts/non-existent-id')
|
||||
|
||||
// Should either show not found or redirect
|
||||
const url = page.url()
|
||||
const hasNotFound = await page.getByText(/not found/i).isVisible().catch(() => false)
|
||||
const redirectedToLogin = url.includes('/login')
|
||||
const redirectedToWorkouts = url.match(/\/workouts\/?$/)
|
||||
|
||||
expect(hasNotFound || redirectedToLogin || redirectedToWorkouts).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workout Delete Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts')
|
||||
})
|
||||
|
||||
test('should open delete confirmation dialog', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find a delete button in the table actions
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).toBeVisible()
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^delete$/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should close delete dialog on Cancel', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).toBeVisible()
|
||||
|
||||
// Click cancel
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,28 @@ export type Json =
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export const MUSIC_GENRES = [
|
||||
'edm', 'hip-hop', 'pop', 'rock', 'latin', 'house',
|
||||
'drum-and-bass', 'dubstep', 'r-and-b', 'country', 'metal', 'ambient',
|
||||
] as const
|
||||
|
||||
export type MusicGenre = typeof MUSIC_GENRES[number]
|
||||
|
||||
export const GENRE_LABELS: Record<MusicGenre, string> = {
|
||||
'edm': 'EDM',
|
||||
'hip-hop': 'Hip Hop',
|
||||
'pop': 'Pop',
|
||||
'rock': 'Rock',
|
||||
'latin': 'Latin',
|
||||
'house': 'House',
|
||||
'drum-and-bass': 'Drum & Bass',
|
||||
'dubstep': 'Dubstep',
|
||||
'r-and-b': 'R&B',
|
||||
'country': 'Country',
|
||||
'metal': 'Metal',
|
||||
'ambient': 'Ambient',
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
@@ -137,6 +159,47 @@ export interface Database {
|
||||
last_login: string | null
|
||||
}
|
||||
}
|
||||
download_jobs: {
|
||||
Row: {
|
||||
id: string
|
||||
playlist_url: string
|
||||
playlist_title: string | null
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items: number
|
||||
completed_items: number
|
||||
failed_items: number
|
||||
created_by: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
playlist_url: string
|
||||
playlist_title?: string | null
|
||||
status?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
total_items?: number
|
||||
completed_items?: number
|
||||
failed_items?: number
|
||||
created_by: string
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['download_jobs']['Insert'], 'id'>>
|
||||
}
|
||||
download_items: {
|
||||
Row: {
|
||||
id: string
|
||||
job_id: string
|
||||
video_id: string
|
||||
title: string | null
|
||||
duration_seconds: number | null
|
||||
thumbnail_url: string | null
|
||||
status: 'pending' | 'downloading' | 'completed' | 'failed'
|
||||
storage_path: string | null
|
||||
public_url: string | null
|
||||
error_message: string | null
|
||||
genre: MusicGenre | null
|
||||
created_at: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
412
admin-web/lib/use-youtube-download.ts
Normal file
412
admin-web/lib/use-youtube-download.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import type { Database, MusicGenre } from "@/lib/supabase";
|
||||
|
||||
type DownloadJob = Database["public"]["Tables"]["download_jobs"]["Row"];
|
||||
type DownloadItem = Database["public"]["Tables"]["download_items"]["Row"];
|
||||
|
||||
export interface JobWithItems extends DownloadJob {
|
||||
items: DownloadItem[];
|
||||
}
|
||||
|
||||
const PROCESS_DELAY_MS = 1000;
|
||||
|
||||
/**
|
||||
* Construct a GET request to a Supabase edge function with query params.
|
||||
* supabase.functions.invoke() doesn't support query params, so we use fetch.
|
||||
*/
|
||||
async function invokeGet<T>(
|
||||
functionName: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const url = new URL(`${supabaseUrl}/functions/v1/${functionName}`);
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(body.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DELETE request to a Supabase edge function with a JSON body.
|
||||
*/
|
||||
async function invokeDelete<T>(
|
||||
functionName: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/functions/v1/${functionName}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PATCH request to a Supabase edge function with a JSON body.
|
||||
*/
|
||||
async function invokePatch<T>(
|
||||
functionName: string,
|
||||
body: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) throw new Error("Not authenticated");
|
||||
|
||||
const supabaseUrl =
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
||||
"http://localhost:54321";
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/functions/v1/${functionName}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ItemWithPlaylist extends DownloadItem {
|
||||
playlist_title: string | null;
|
||||
}
|
||||
|
||||
export function useYouTubeDownload() {
|
||||
const [jobs, setJobs] = useState<DownloadJob[]>([]);
|
||||
const [allItems, setAllItems] = useState<ItemWithPlaylist[]>([]);
|
||||
const [activeJob, setActiveJob] = useState<JobWithItems | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isClassifying, setIsClassifying] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/** Fetch all jobs (list view). */
|
||||
const fetchJobs = useCallback(async () => {
|
||||
const data = await invokeGet<{ jobs: DownloadJob[] }>("youtube-status");
|
||||
setJobs(data.jobs);
|
||||
return data.jobs;
|
||||
}, []);
|
||||
|
||||
/** Fetch ALL download items across all jobs, enriched with playlist title. */
|
||||
const fetchAllItems = useCallback(async () => {
|
||||
// Fetch all items via Supabase directly (RLS ensures admin-only).
|
||||
// Cast needed because the Database type only defines Row (no Insert/Update)
|
||||
// for download_items, causing Supabase client to infer `never`.
|
||||
const { data: items, error: itemsErr } = (await supabase
|
||||
.from("download_items")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })) as {
|
||||
data: DownloadItem[] | null;
|
||||
error: { message: string } | null;
|
||||
};
|
||||
|
||||
if (itemsErr) throw new Error(itemsErr.message);
|
||||
|
||||
// Build a map of job_id -> playlist_title from the current jobs list,
|
||||
// or fetch jobs if we don't have them yet.
|
||||
let jobMap: Record<string, string | null> = {};
|
||||
let currentJobs = jobs;
|
||||
if (currentJobs.length === 0) {
|
||||
const data = await invokeGet<{ jobs: DownloadJob[] }>("youtube-status");
|
||||
currentJobs = data.jobs;
|
||||
setJobs(currentJobs);
|
||||
}
|
||||
for (const j of currentJobs) {
|
||||
jobMap[j.id] = j.playlist_title;
|
||||
}
|
||||
|
||||
const enriched: ItemWithPlaylist[] = (items ?? []).map((item) => ({
|
||||
...item,
|
||||
playlist_title: jobMap[item.job_id] ?? null,
|
||||
}));
|
||||
|
||||
setAllItems(enriched);
|
||||
return enriched;
|
||||
}, [jobs]);
|
||||
|
||||
/** Fetch a single job with its items. */
|
||||
const refreshStatus = useCallback(async (jobId: string) => {
|
||||
const data = await invokeGet<{ job: DownloadJob; items: DownloadItem[] }>(
|
||||
"youtube-status",
|
||||
{ jobId }
|
||||
);
|
||||
const jobWithItems: JobWithItems = { ...data.job, items: data.items };
|
||||
setActiveJob(jobWithItems);
|
||||
|
||||
// Also update the job in the list
|
||||
setJobs((prev) =>
|
||||
prev.map((j) => (j.id === jobId ? data.job : j))
|
||||
);
|
||||
|
||||
return jobWithItems;
|
||||
}, []);
|
||||
|
||||
/** Import a playlist: creates a job + download_items rows. */
|
||||
const importPlaylist = useCallback(
|
||||
async (playlistUrl: string, genre?: MusicGenre) => {
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-playlist",
|
||||
{ body: { playlistUrl, genre: genre || null } }
|
||||
);
|
||||
if (error) throw new Error(error.message ?? "Import failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
// Refresh the jobs list and select the new job
|
||||
await fetchJobs();
|
||||
if (data.jobId) {
|
||||
await refreshStatus(data.jobId);
|
||||
}
|
||||
|
||||
return data as {
|
||||
jobId: string;
|
||||
playlistTitle: string;
|
||||
totalItems: number;
|
||||
};
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[fetchJobs, refreshStatus]
|
||||
);
|
||||
|
||||
/** Process all pending items for a job, one at a time. */
|
||||
const startProcessing = useCallback(
|
||||
async (jobId: string) => {
|
||||
// Abort any existing processing loop
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
let done = false;
|
||||
|
||||
while (!done && !controller.signal.aborted) {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-process",
|
||||
{ body: { jobId } }
|
||||
);
|
||||
|
||||
if (error) throw new Error(error.message ?? "Processing failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
done = data.done === true;
|
||||
|
||||
// Refresh the job status to get updated items
|
||||
await refreshStatus(jobId);
|
||||
|
||||
// Delay between calls to avoid hammering the function
|
||||
if (!done && !controller.signal.aborted) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, PROCESS_DELAY_MS);
|
||||
controller.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// Graceful stop — not an error
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
abortRef.current = null;
|
||||
// Final refresh to get latest state
|
||||
await refreshStatus(jobId).catch(() => {});
|
||||
}
|
||||
},
|
||||
[refreshStatus]
|
||||
);
|
||||
|
||||
/** Stop the current processing loop. */
|
||||
const stopProcessing = useCallback(() => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Delete a job, its items, and associated storage files. */
|
||||
const deleteJob = useCallback(
|
||||
async (jobId: string) => {
|
||||
await invokeDelete<{ deleted: boolean }>("youtube-status", { jobId });
|
||||
|
||||
// Remove the job from local state
|
||||
setJobs((prev) => prev.filter((j) => j.id !== jobId));
|
||||
|
||||
// Clear active job if it was the deleted one
|
||||
setActiveJob((prev) => (prev?.id === jobId ? null : prev));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Delete a single download item and its audio file from storage. */
|
||||
const deleteItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
const result = await invokeDelete<{
|
||||
deleted: boolean;
|
||||
itemId: string;
|
||||
jobId: string;
|
||||
}>("youtube-status", { itemId });
|
||||
|
||||
// Remove from allItems
|
||||
setAllItems((prev) => prev.filter((i) => i.id !== itemId));
|
||||
|
||||
// Remove from activeJob items if present
|
||||
setActiveJob((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.filter((i) => i.id !== itemId),
|
||||
};
|
||||
});
|
||||
|
||||
// Update the parent job counters in jobs list
|
||||
setJobs((prev) =>
|
||||
prev.map((j) => {
|
||||
if (j.id !== result.jobId) return j;
|
||||
// We don't know the item status here, so just decrement total.
|
||||
// The next fetchJobs() will reconcile exact counts from the server.
|
||||
return {
|
||||
...j,
|
||||
total_items: Math.max(0, j.total_items - 1),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Update the genre on a single download item. */
|
||||
const updateItemGenre = useCallback(
|
||||
async (itemId: string, genre: MusicGenre | null) => {
|
||||
await invokePatch<{ updated: boolean }>("youtube-status", {
|
||||
itemId,
|
||||
genre,
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setActiveJob((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === itemId ? { ...item, genre } : item
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/** Re-classify genres for a job's items via YouTube metadata + Gemini. */
|
||||
const reclassifyJob = useCallback(
|
||||
async (jobId: string, force = false) => {
|
||||
setIsClassifying(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke(
|
||||
"youtube-classify",
|
||||
{ body: { jobId, force } }
|
||||
);
|
||||
if (error) throw new Error(error.message ?? "Classification failed");
|
||||
if (data?.error) throw new Error(data.error);
|
||||
|
||||
// Refresh job items and library to reflect updated genres
|
||||
await refreshStatus(jobId);
|
||||
await fetchAllItems().catch(() => {});
|
||||
|
||||
return data as { classified: number; skipped: number };
|
||||
} finally {
|
||||
setIsClassifying(false);
|
||||
}
|
||||
},
|
||||
[refreshStatus, fetchAllItems]
|
||||
);
|
||||
|
||||
return {
|
||||
jobs,
|
||||
allItems,
|
||||
activeJob,
|
||||
isProcessing,
|
||||
isImporting,
|
||||
isClassifying,
|
||||
fetchJobs,
|
||||
fetchAllItems,
|
||||
refreshStatus,
|
||||
importPlaylist,
|
||||
startProcessing,
|
||||
stopProcessing,
|
||||
deleteJob,
|
||||
deleteItem,
|
||||
updateItemGenre,
|
||||
reclassifyJob,
|
||||
};
|
||||
}
|
||||
63
admin-web/package-lock.json
generated
63
admin-web/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -2233,6 +2234,22 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -10872,6 +10889,52 @@
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
|
||||
12
app.json
12
app.json
@@ -11,7 +11,17 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.millianlmx.tabatafit",
|
||||
"buildNumber": "1"
|
||||
"buildNumber": "1",
|
||||
"infoPlist": {
|
||||
"NSHealthShareUsageDescription": "TabataFit uses HealthKit to read and write workout data including heart rate, calories burned, and exercise minutes.",
|
||||
"NSHealthUpdateUsageDescription": "TabataFit saves your workout sessions to Apple Health so you can track your fitness progress.",
|
||||
"NSCameraUsageDescription": "TabataFit uses the camera for profile photos and workout form checks.",
|
||||
"NSUserTrackingUsageDescription": "TabataFit uses this to provide personalized workout recommendations.",
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* TabataFit Tab Layout
|
||||
* Native iOS tabs with liquid glass effect
|
||||
* 5 tabs: Home, Workouts, Activity, Browse, Profile
|
||||
* 4 tabs: Home, Workouts, Activity, Profile
|
||||
* Redirects to onboarding if not completed
|
||||
*/
|
||||
|
||||
@@ -28,9 +28,9 @@ export default function TabLayout() {
|
||||
<Label>{t('tabs.home')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="workouts">
|
||||
<NativeTabs.Trigger name="explore">
|
||||
<Icon sf={{ default: 'flame', selected: 'flame.fill' }} />
|
||||
<Label>{t('tabs.workouts')}</Label>
|
||||
<Label>{t('tabs.explore')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="activity">
|
||||
@@ -38,11 +38,6 @@ export default function TabLayout() {
|
||||
<Label>{t('tabs.activity')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="browse">
|
||||
<Icon sf={{ default: 'square.grid.2x2', selected: 'square.grid.2x2.fill' }} />
|
||||
<Label>{t('tabs.browse')}</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="profile">
|
||||
<Icon sf={{ default: 'person', selected: 'person.fill' }} />
|
||||
<Label>{t('tabs.profile')}</Label>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* Premium stats dashboard — streak, rings, weekly chart, history
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Dimensions } from 'react-native'
|
||||
import { View, StyleSheet, ScrollView, Dimensions, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
import Svg, { Circle } from 'react-native-svg'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -45,39 +47,32 @@ function StatRing({
|
||||
const progress = Math.min(value / max, 1)
|
||||
const strokeDashoffset = circumference * (1 - progress)
|
||||
|
||||
// We'll use a View-based ring since SVG isn't available
|
||||
// Use border trick for a circular progress indicator
|
||||
return (
|
||||
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Svg width={size} height={size}>
|
||||
{/* Track */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: strokeWidth,
|
||||
borderColor: colors.bg.overlay2,
|
||||
}}
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={colors.bg.overlay2}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Fill — simplified: show a colored ring proportional to progress */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: strokeWidth,
|
||||
borderColor: color,
|
||||
borderTopColor: progress > 0.25 ? color : 'transparent',
|
||||
borderRightColor: progress > 0.5 ? color : 'transparent',
|
||||
borderBottomColor: progress > 0.75 ? color : 'transparent',
|
||||
borderLeftColor: progress > 0 ? color : 'transparent',
|
||||
transform: [{ rotate: '-90deg' }],
|
||||
opacity: progress > 0 ? 1 : 0.3,
|
||||
}}
|
||||
{/* Progress */}
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
opacity={progress > 0 ? 1 : 0.3}
|
||||
/>
|
||||
</View>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +91,7 @@ function StatCard({
|
||||
value: number
|
||||
max: number
|
||||
color: string
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
icon: IconName
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
@@ -113,7 +108,7 @@ function StatCard({
|
||||
{label}
|
||||
</StyledText>
|
||||
</View>
|
||||
<Ionicons name={icon} size={18} color={color} />
|
||||
<Icon name={icon} size={18} tintColor={color} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -155,6 +150,42 @@ function WeeklyBar({
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EMPTY STATE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function EmptyState({ onStartWorkout }: { onStartWorkout: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.emptyIconCircle}>
|
||||
<Icon name="flame" size={48} tintColor={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary} style={styles.emptyTitle}>
|
||||
{t('screens:activity.emptyTitle')}
|
||||
</StyledText>
|
||||
<StyledText size={15} color={colors.text.tertiary} style={styles.emptySubtitle}>
|
||||
{t('screens:activity.emptySubtitle')}
|
||||
</StyledText>
|
||||
<Pressable style={styles.emptyCtaButton} onPress={onStartWorkout}>
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Icon name="play.fill" size={18} tintColor="#FFFFFF" style={{ marginRight: SPACING[2] }} />
|
||||
<StyledText size={16} weight="semibold" color="#FFFFFF">
|
||||
{t('screens:activity.startFirstWorkout')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -164,6 +195,7 @@ const DAY_KEYS = ['days.sun', 'days.mon', 'days.tue', 'days.wed', 'days.thu', 'd
|
||||
export default function ActivityScreen() {
|
||||
const { t } = useTranslation()
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
@@ -216,6 +248,11 @@ export default function ActivityScreen() {
|
||||
{t('screens:activity.title')}
|
||||
</StyledText>
|
||||
|
||||
{/* Empty state when no history */}
|
||||
{history.length === 0 ? (
|
||||
<EmptyState onStartWorkout={() => router.push('/(tabs)/explore' as any)} />
|
||||
) : (
|
||||
<>
|
||||
{/* Streak Banner */}
|
||||
<View style={styles.streakBanner}>
|
||||
<LinearGradient
|
||||
@@ -226,7 +263,7 @@ export default function ActivityScreen() {
|
||||
/>
|
||||
<View style={styles.streakRow}>
|
||||
<View style={styles.streakIconWrap}>
|
||||
<Ionicons name="flame" size={28} color="#FFFFFF" />
|
||||
<Icon name="flame.fill" size={28} tintColor="#FFFFFF" />
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<StyledText size={28} weight="bold" color="#FFFFFF">
|
||||
@@ -254,28 +291,28 @@ export default function ActivityScreen() {
|
||||
value={totalWorkouts}
|
||||
max={100}
|
||||
color={BRAND.PRIMARY}
|
||||
icon="barbell-outline"
|
||||
icon="dumbbell"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('screens:activity.minutes')}
|
||||
value={totalMinutes}
|
||||
max={300}
|
||||
color={PHASE.REST}
|
||||
icon="time-outline"
|
||||
icon="clock"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('screens:activity.calories')}
|
||||
value={totalCalories}
|
||||
max={5000}
|
||||
color={BRAND.SECONDARY}
|
||||
icon="flash-outline"
|
||||
icon="bolt"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('screens:activity.bestStreak')}
|
||||
value={streak.longest}
|
||||
max={30}
|
||||
color={BRAND.SUCCESS}
|
||||
icon="trending-up-outline"
|
||||
icon="arrow.up.right"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -358,10 +395,10 @@ export default function ActivityScreen() {
|
||||
: { backgroundColor: 'rgba(255, 255, 255, 0.04)' },
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={a.unlocked ? 'trophy' : 'lock-closed'}
|
||||
<Icon
|
||||
name={a.unlocked ? 'trophy.fill' : 'lock.fill'}
|
||||
size={22}
|
||||
color={a.unlocked ? BRAND.PRIMARY : colors.text.hint}
|
||||
tintColor={a.unlocked ? BRAND.PRIMARY : colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
<StyledText
|
||||
@@ -377,6 +414,8 @@ export default function ActivityScreen() {
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -549,5 +588,41 @@ function createStyles(colors: ThemeColors) {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Empty State
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: SPACING[10],
|
||||
paddingHorizontal: SPACING[6],
|
||||
},
|
||||
emptyIconCircle: {
|
||||
width: 96,
|
||||
height: 96,
|
||||
borderRadius: 48,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
emptyTitle: {
|
||||
textAlign: 'center' as const,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
emptySubtitle: {
|
||||
textAlign: 'center' as const,
|
||||
lineHeight: 22,
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
emptyCtaButton: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
height: 52,
|
||||
paddingHorizontal: SPACING[8],
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
/**
|
||||
* TabataFit Browse Screen - Premium Redesign
|
||||
* React Native UI with glassmorphism
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, TextInput } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import {
|
||||
COLLECTIONS,
|
||||
getFeaturedCollection,
|
||||
WORKOUTS,
|
||||
} from '@/src/shared/data'
|
||||
import { useTranslatedCollections, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { WorkoutCard } from '@/src/shared/components/WorkoutCard'
|
||||
import { CollectionCard } from '@/src/shared/components/CollectionCard'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import type { WorkoutCategory } from '@/src/shared/types'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 34,
|
||||
TITLE: 28,
|
||||
TITLE_2: 22,
|
||||
HEADLINE: 17,
|
||||
SUBHEADLINE: 15,
|
||||
CAPTION_1: 12,
|
||||
CAPTION_2: 11,
|
||||
}
|
||||
|
||||
const CATEGORIES: { id: WorkoutCategory | 'all'; translationKey: string }[] = [
|
||||
{ id: 'all', translationKey: 'common:categories.all' },
|
||||
{ id: 'full-body', translationKey: 'common:categories.fullBody' },
|
||||
{ id: 'core', translationKey: 'common:categories.core' },
|
||||
{ id: 'upper-body', translationKey: 'common:categories.upperBody' },
|
||||
{ id: 'lower-body', translationKey: 'common:categories.lowerBody' },
|
||||
{ id: 'cardio', translationKey: 'common:categories.cardio' },
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function BrowseScreen() {
|
||||
const { t } = useTranslation()
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<WorkoutCategory | 'all'>('all')
|
||||
|
||||
const featuredCollection = getFeaturedCollection()
|
||||
const translatedCollections = useTranslatedCollections(COLLECTIONS)
|
||||
const translatedWorkouts = useTranslatedWorkouts(WORKOUTS)
|
||||
|
||||
// Filter workouts based on search and category
|
||||
const filteredWorkouts = useMemo(() => {
|
||||
let filtered = translatedWorkouts
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(w) =>
|
||||
w.title.toLowerCase().includes(query) ||
|
||||
w.category.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedCategory !== 'all') {
|
||||
filtered = filtered.filter((w) => w.category === selectedCategory)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}, [translatedWorkouts, searchQuery, selectedCategory])
|
||||
|
||||
const handleWorkoutPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${id}`)
|
||||
}
|
||||
|
||||
const handleCollectionPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/collection/${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:browse.title')}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Ionicons name="search" size={20} color={colors.text.tertiary} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
placeholder={t('screens:browse.searchPlaceholder') || 'Search workouts...'}
|
||||
placeholderTextColor={colors.text.tertiary}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
/>
|
||||
{searchQuery.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => setSearchQuery('')}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color={colors.text.tertiary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Category Filter Chips */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoriesScroll}
|
||||
style={styles.categoriesContainer}
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === cat.id && styles.categoryChipActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
setSelectedCategory(cat.id)
|
||||
}}
|
||||
>
|
||||
{selectedCategory === cat.id && (
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
size={14}
|
||||
weight={selectedCategory === cat.id ? 'semibold' : 'medium'}
|
||||
color={selectedCategory === cat.id ? '#FFFFFF' : colors.text.secondary}
|
||||
>
|
||||
{t(cat.translationKey)}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Featured Collection */}
|
||||
{featuredCollection && !searchQuery && selectedCategory === 'all' && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:browse.featured')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<CollectionCard
|
||||
collection={featuredCollection}
|
||||
onPress={() => handleCollectionPress(featuredCollection.id)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Collections Grid */}
|
||||
{!searchQuery && selectedCategory === 'all' && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:browse.collections')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.collectionsGrid}>
|
||||
{translatedCollections.map((collection) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onPress={() => handleCollectionPress(collection.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* All Workouts Grid */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{searchQuery ? t('screens:browse.searchResults') || 'Results' : t('screens:browse.allWorkouts') || 'All Workouts'}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary}>
|
||||
{filteredWorkouts.length} {t('plurals.workout', { count: filteredWorkouts.length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{filteredWorkouts.length > 0 ? (
|
||||
<View style={styles.workoutsGrid}>
|
||||
{filteredWorkouts.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
variant="grid"
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="fitness-outline" size={48} color={colors.text.tertiary} />
|
||||
<StyledText
|
||||
size={FONTS.HEADLINE}
|
||||
weight="medium"
|
||||
color={colors.text.secondary}
|
||||
style={{ marginTop: SPACING[4] }}
|
||||
>
|
||||
{t('screens:browse.noResults') || 'No workouts found'}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
size={FONTS.SUBHEADLINE}
|
||||
color={colors.text.tertiary}
|
||||
style={{ marginTop: SPACING[2], textAlign: 'center' }}
|
||||
>
|
||||
{t('screens:browse.tryDifferentSearch') || 'Try a different search or category'}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Search Bar
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 48,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
paddingHorizontal: SPACING[4],
|
||||
marginBottom: SPACING[4],
|
||||
overflow: 'hidden',
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
marginLeft: SPACING[3],
|
||||
marginRight: SPACING[2],
|
||||
fontSize: FONTS.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
height: '100%',
|
||||
},
|
||||
|
||||
// Categories
|
||||
categoriesContainer: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
categoriesScroll: {
|
||||
gap: SPACING[2],
|
||||
paddingRight: SPACING[4],
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
},
|
||||
categoryChipActive: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: `${BRAND.PRIMARY}30`,
|
||||
},
|
||||
|
||||
// Sections
|
||||
section: {
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Collections
|
||||
collectionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
|
||||
// Workouts Grid
|
||||
workoutsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
|
||||
// Empty State
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[12],
|
||||
},
|
||||
})
|
||||
}
|
||||
1005
app/(tabs)/explore.tsx
Normal file
1005
app/(tabs)/explore.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,99 +1,401 @@
|
||||
/**
|
||||
* TabataFit Home Screen - Premium Redesign
|
||||
* React Native UI with glassmorphism
|
||||
* TabataFit Home Screen - 3 Program Design
|
||||
* Premium Apple Fitness+ inspired layout
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, Text as RNText } from 'react-native'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Animated } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useRef, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics, useFeaturedWorkouts, usePopularWorkouts, useCollections } from '@/src/shared/hooks'
|
||||
import { useUserStore, useActivityStore } from '@/src/shared/stores'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useUserStore, useProgramStore, useActivityStore, getWeeklyActivity } from '@/src/shared/stores'
|
||||
import { PROGRAMS, ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { WorkoutCard } from '@/src/shared/components/WorkoutCard'
|
||||
import { CollectionCard } from '@/src/shared/components/CollectionCard'
|
||||
import { WorkoutCardSkeleton, CollectionCardSkeleton, StatsCardSkeleton } from '@/src/shared/components/loading/Skeleton'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import type { WorkoutCategory } from '@/src/shared/types'
|
||||
import type { ProgramId } from '@/src/shared/types'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
// Feature flags — disable incomplete features
|
||||
const FEATURE_FLAGS = {
|
||||
ASSESSMENT_ENABLED: false, // Assessment player not yet implemented
|
||||
}
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 34,
|
||||
TITLE: 28,
|
||||
TITLE_2: 22,
|
||||
HEADLINE: 17,
|
||||
SUBHEADLINE: 15,
|
||||
CAPTION_1: 12,
|
||||
CAPTION_2: 11,
|
||||
BODY: 16,
|
||||
CAPTION: 13,
|
||||
}
|
||||
|
||||
const CATEGORIES: { id: WorkoutCategory | 'all'; key: string }[] = [
|
||||
{ id: 'all', key: 'all' },
|
||||
{ id: 'full-body', key: 'fullBody' },
|
||||
{ id: 'core', key: 'core' },
|
||||
{ id: 'upper-body', key: 'upperBody' },
|
||||
{ id: 'lower-body', key: 'lowerBody' },
|
||||
{ id: 'cardio', key: 'cardio' },
|
||||
]
|
||||
// Program metadata for display
|
||||
const PROGRAM_META: Record<ProgramId, { icon: IconName; gradient: [string, string]; accent: string }> = {
|
||||
'upper-body': {
|
||||
icon: 'dumbbell',
|
||||
gradient: ['#FF6B35', '#FF3B30'],
|
||||
accent: '#FF6B35',
|
||||
},
|
||||
'lower-body': {
|
||||
icon: 'figure.walk',
|
||||
gradient: ['#30D158', '#28A745'],
|
||||
accent: '#30D158',
|
||||
},
|
||||
'full-body': {
|
||||
icon: 'flame',
|
||||
gradient: ['#5AC8FA', '#007AFF'],
|
||||
accent: '#5AC8FA',
|
||||
},
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PROGRAM CARD
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ProgramCard({
|
||||
programId,
|
||||
onPress,
|
||||
}: {
|
||||
programId: ProgramId
|
||||
onPress: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('screens')
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const program = PROGRAMS[programId]
|
||||
const meta = PROGRAM_META[programId]
|
||||
const programStatus = useProgramStore((s) => s.getProgramStatus(programId))
|
||||
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
|
||||
|
||||
// Press animation
|
||||
const scaleValue = useRef(new Animated.Value(1)).current
|
||||
const handlePressIn = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 0.97,
|
||||
useNativeDriver: true,
|
||||
speed: 50,
|
||||
bounciness: 4,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
const handlePressOut = useCallback(() => {
|
||||
Animated.spring(scaleValue, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
speed: 30,
|
||||
bounciness: 6,
|
||||
}).start()
|
||||
}, [scaleValue])
|
||||
|
||||
const statusText = {
|
||||
'not-started': t('programs.status.notStarted'),
|
||||
'in-progress': `${completion}% ${t('programs.status.complete')}`,
|
||||
'completed': t('programs.status.completed'),
|
||||
}[programStatus]
|
||||
|
||||
const handlePress = () => {
|
||||
haptics.buttonTap()
|
||||
onPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.programCard}
|
||||
testID={`program-card-${programId}`}
|
||||
>
|
||||
{/* Glass Background */}
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Color Gradient Overlay */}
|
||||
<LinearGradient
|
||||
colors={[meta.gradient[0] + '40', meta.gradient[1] + '18', 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
{/* Top Accent Line */}
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.accentLine}
|
||||
/>
|
||||
|
||||
<View style={styles.programCardContent}>
|
||||
{/* Icon + Title Row */}
|
||||
<View style={styles.programCardHeader}>
|
||||
{/* Gradient Icon Circle */}
|
||||
<View style={styles.programIconWrapper}>
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.programIconGradient}
|
||||
/>
|
||||
<View style={styles.programIconInner}>
|
||||
<Icon name={meta.icon} size={24} tintColor="#FFFFFF" />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.programHeaderText}>
|
||||
<View style={styles.programTitleRow}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="bold" color={colors.text.primary} style={{ flex: 1 }}>
|
||||
{t(`content:programs.${program.id}.title`)}
|
||||
</StyledText>
|
||||
{programStatus !== 'not-started' && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: meta.accent + '20', borderColor: meta.accent + '35' }]}>
|
||||
<StyledText size={11} weight="semibold" color={meta.accent}>
|
||||
{statusText}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} numberOfLines={2} style={{ lineHeight: 18 }}>
|
||||
{t(`content:programs.${program.id}.description`)}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Progress Bar (if started) */}
|
||||
{programStatus !== 'not-started' && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={styles.progressFillWrapper}>
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.max(completion, 2)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<StyledText size={11} color={colors.text.tertiary}>
|
||||
{programStatus === 'completed'
|
||||
? t('programs.allWorkoutsComplete')
|
||||
: `${completion}% ${t('programs.complete')}`
|
||||
}
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stats — inline text, not chips */}
|
||||
<StyledText size={12} color={colors.text.tertiary} style={styles.programMeta}>
|
||||
{program.durationWeeks} {t('programs.weeks')} · {program.workoutsPerWeek}×{t('programs.perWeek')} · {program.totalWorkouts} {t('programs.workouts')}
|
||||
</StyledText>
|
||||
|
||||
{/* Premium CTA Button — only interactive element */}
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
styles.ctaButtonWrapper,
|
||||
{ transform: [{ scale: scaleValue }] },
|
||||
]}
|
||||
onPress={handlePress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
testID={`program-${programId}-cta`}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={meta.gradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.ctaButton}
|
||||
>
|
||||
<StyledText size={15} weight="semibold" color="#FFFFFF">
|
||||
{programStatus === 'not-started'
|
||||
? t('programs.startProgram')
|
||||
: programStatus === 'completed'
|
||||
? t('programs.restart')
|
||||
: t('programs.continue')
|
||||
}
|
||||
</StyledText>
|
||||
<Icon
|
||||
name={programStatus === 'completed' ? 'arrow.clockwise' : 'arrow.right'}
|
||||
size={17}
|
||||
tintColor="#FFFFFF"
|
||||
style={styles.ctaIcon}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</AnimatedPressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// QUICK STATS ROW
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function QuickStats() {
|
||||
const { t } = useTranslation('screens')
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const weeklyActivity = useMemo(() => getWeeklyActivity(history), [history])
|
||||
const thisWeekCount = weeklyActivity.filter((d) => d.completed).length
|
||||
const totalMinutes = useMemo(() => history.reduce((sum, r) => sum + r.durationMinutes, 0), [history])
|
||||
|
||||
const stats = [
|
||||
{ icon: 'flame.fill' as const, value: streak.current, label: t('home.statsStreak'), color: BRAND.PRIMARY },
|
||||
{ icon: 'calendar' as const, value: `${thisWeekCount}/7`, label: t('home.statsThisWeek'), color: '#5AC8FA' },
|
||||
{ icon: 'clock' as const, value: totalMinutes, label: t('home.statsMinutes'), color: '#30D158' },
|
||||
]
|
||||
|
||||
return (
|
||||
<View style={styles.quickStatsRow}>
|
||||
{stats.map((stat) => (
|
||||
<View key={stat.label} style={styles.quickStatPill}>
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Icon name={stat.icon} size={16} tintColor={stat.color} />
|
||||
<StyledText size={17} weight="bold" color={colors.text.primary} style={{ fontVariant: ['tabular-nums'] }}>
|
||||
{String(stat.value)}
|
||||
</StyledText>
|
||||
<StyledText size={11} color={colors.text.tertiary}>
|
||||
{stat.label}
|
||||
</StyledText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ASSESSMENT CARD
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function AssessmentCard({ onPress }: { onPress: () => void }) {
|
||||
const { t } = useTranslation('screens')
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const isCompleted = useProgramStore((s) => s.assessment.isCompleted)
|
||||
|
||||
if (isCompleted) return null
|
||||
|
||||
const handlePress = () => {
|
||||
haptics.buttonTap()
|
||||
onPress()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.assessmentCard}
|
||||
onPress={handlePress}
|
||||
testID="assessment-card"
|
||||
>
|
||||
{/* Glass Background */}
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
{/* Subtle brand gradient overlay */}
|
||||
<LinearGradient
|
||||
colors={[`${BRAND.PRIMARY}18`, 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<View style={styles.assessmentContent}>
|
||||
{/* Gradient Icon Circle */}
|
||||
<View style={styles.assessmentIconCircle}>
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, '#FF3B30']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<View style={styles.assessmentIconInner}>
|
||||
<Icon name="clipboard" size={22} tintColor="#FFFFFF" />
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.assessmentText}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary}>
|
||||
{t('assessment.title')}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={{ marginTop: 2 }}>
|
||||
{ASSESSMENT_WORKOUT.duration} {t('assessment.duration')} · {ASSESSMENT_WORKOUT.exercises.length} {t('assessment.movements')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.assessmentArrow}>
|
||||
<Icon name="arrow.right" size={16} tintColor={BRAND.PRIMARY} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation('screens')
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const haptics = useHaptics()
|
||||
const userName = useUserStore((s) => s.profile.name)
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const recentWorkouts = useMemo(() => history.slice(0, 3), [history])
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<WorkoutCategory | 'all'>('all')
|
||||
|
||||
// React Query hooks for live data
|
||||
const { data: featuredWorkouts = [], isLoading: isLoadingFeatured } = useFeaturedWorkouts()
|
||||
const { data: popularWorkouts = [], isLoading: isLoadingPopular } = usePopularWorkouts(6)
|
||||
const { data: collections = [], isLoading: isLoadingCollections } = useCollections()
|
||||
|
||||
const featured = featuredWorkouts[0]
|
||||
|
||||
const filteredWorkouts = useMemo(() => {
|
||||
if (selectedCategory === 'all') return popularWorkouts
|
||||
return popularWorkouts.filter((w) => w.category === selectedCategory)
|
||||
}, [popularWorkouts, selectedCategory])
|
||||
const selectedProgram = useProgramStore((s) => s.selectedProgramId)
|
||||
const changeProgram = useProgramStore((s) => s.changeProgram)
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
|
||||
const greeting = (() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return t('greetings.morning')
|
||||
if (hour < 18) return t('greetings.afternoon')
|
||||
return t('greetings.evening')
|
||||
if (hour < 12) return t('common:greetings.morning')
|
||||
if (hour < 18) return t('common:greetings.afternoon')
|
||||
return t('common:greetings.evening')
|
||||
})()
|
||||
|
||||
const handleWorkoutPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${id}`)
|
||||
const handleProgramPress = (programId: ProgramId) => {
|
||||
// Navigate to program detail
|
||||
router.push(`/program/${programId}` as any)
|
||||
}
|
||||
|
||||
const handleCollectionPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/collection/${id}`)
|
||||
const handleAssessmentPress = () => {
|
||||
router.push('/assessment' as any)
|
||||
}
|
||||
|
||||
const handleSwitchProgram = () => {
|
||||
haptics.buttonTap()
|
||||
changeProgram(null as any)
|
||||
}
|
||||
|
||||
const programOrder: ProgramId[] = ['upper-body', 'lower-body', 'full-body']
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Ambient gradient glow at top */}
|
||||
<LinearGradient
|
||||
colors={['rgba(255, 107, 53, 0.06)', 'rgba(255, 107, 53, 0.02)', 'transparent']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
style={styles.ambientGlow}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
@@ -101,142 +403,76 @@ export default function HomeScreen() {
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<View style={styles.heroSection}>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color={colors.text.tertiary}>
|
||||
{greeting}
|
||||
</StyledText>
|
||||
<View style={styles.heroHeader}>
|
||||
<StyledText
|
||||
size={FONTS.LARGE_TITLE}
|
||||
weight="bold"
|
||||
color={colors.text.primary}
|
||||
style={styles.heroTitle}
|
||||
>
|
||||
{userName}
|
||||
<View style={styles.heroGreetingRow}>
|
||||
<StyledText size={FONTS.BODY} color={colors.text.tertiary}>
|
||||
{greeting}
|
||||
</StyledText>
|
||||
<Pressable style={styles.profileButton}>
|
||||
<Ionicons name="person-circle-outline" size={40} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Featured Workout */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:home.featured')}
|
||||
</StyledText>
|
||||
</View>
|
||||
{isLoadingFeatured ? (
|
||||
<WorkoutCardSkeleton />
|
||||
) : featured ? (
|
||||
<WorkoutCard
|
||||
workout={featured}
|
||||
variant="featured"
|
||||
onPress={() => handleWorkoutPress(featured.id)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{/* Category Filter */}
|
||||
<View style={styles.section}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoriesScroll}
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<Pressable
|
||||
key={cat.id}
|
||||
style={[
|
||||
styles.categoryChip,
|
||||
selectedCategory === cat.id && styles.categoryChipActive,
|
||||
]}
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
setSelectedCategory(cat.id)
|
||||
}}
|
||||
>
|
||||
{selectedCategory === cat.id && (
|
||||
<BlurView
|
||||
intensity={colors.glass.blurMedium}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
size={14}
|
||||
weight={selectedCategory === cat.id ? 'semibold' : 'medium'}
|
||||
color={selectedCategory === cat.id ? colors.text.primary : colors.text.secondary}
|
||||
>
|
||||
{t(`categories.${cat.key}`)}
|
||||
{/* Inline streak badge */}
|
||||
{streak.current > 0 && (
|
||||
<View style={styles.streakBadge}>
|
||||
<Icon name="flame.fill" size={13} tintColor={BRAND.PRIMARY} />
|
||||
<StyledText size={12} weight="bold" color={BRAND.PRIMARY} style={{ fontVariant: ['tabular-nums'] }}>
|
||||
{streak.current}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroName}>
|
||||
{userName}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.secondary} style={styles.heroSubtitle}>
|
||||
{selectedProgram
|
||||
? t('home.continueYourJourney')
|
||||
: t('home.chooseYourPath')
|
||||
}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Popular Workouts - Horizontal */}
|
||||
<View style={styles.section}>
|
||||
{/* Quick Stats Row */}
|
||||
<QuickStats />
|
||||
|
||||
{/* Assessment Card (if not completed and feature enabled) */}
|
||||
{FEATURE_FLAGS.ASSESSMENT_ENABLED && (
|
||||
<AssessmentCard onPress={handleAssessmentPress} />
|
||||
)}
|
||||
|
||||
{/* Program Cards */}
|
||||
<View style={styles.programsSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:home.popularThisWeek')}
|
||||
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
|
||||
{t('home.yourPrograms')}
|
||||
</StyledText>
|
||||
<StyledText size={FONTS.CAPTION} color={colors.text.tertiary} style={styles.sectionSubtitle}>
|
||||
{t('home.programsSubtitle')}
|
||||
</StyledText>
|
||||
<Pressable hitSlop={8}>
|
||||
<StyledText size={FONTS.SUBHEADLINE} color={BRAND.PRIMARY} weight="medium">
|
||||
{t('seeAll')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.workoutsScroll}
|
||||
|
||||
{programOrder.map((programId) => (
|
||||
<ProgramCard
|
||||
key={programId}
|
||||
programId={programId}
|
||||
onPress={() => handleProgramPress(programId)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Switch Program Option (if has progress) */}
|
||||
{selectedProgram && (
|
||||
<Pressable
|
||||
style={styles.switchProgramButton}
|
||||
onPress={handleSwitchProgram}
|
||||
>
|
||||
{isLoadingPopular ? (
|
||||
// Loading skeletons
|
||||
<>
|
||||
<WorkoutCardSkeleton />
|
||||
<WorkoutCardSkeleton />
|
||||
<WorkoutCardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
filteredWorkouts.map((workout: any) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
workout={workout}
|
||||
variant="horizontal"
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Collections Grid */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:home.collections')}
|
||||
<BlurView
|
||||
intensity={colors.glass.blurLight}
|
||||
tint={colors.glass.blurTint}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Icon name="shuffle" size={16} tintColor={colors.text.secondary} />
|
||||
<StyledText size={14} weight="medium" color={colors.text.secondary}>
|
||||
{t('home.switchProgram')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.collectionsGrid}>
|
||||
{isLoadingCollections ? (
|
||||
// Loading skeletons for collections
|
||||
<>
|
||||
<CollectionCardSkeleton />
|
||||
<CollectionCardSkeleton />
|
||||
</>
|
||||
) : (
|
||||
collections.map((collection) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onPress={() => handleCollectionPress(collection.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -259,67 +495,241 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Ambient gradient glow
|
||||
ambientGlow: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
},
|
||||
|
||||
// Hero Section
|
||||
heroSection: {
|
||||
marginBottom: SPACING[6],
|
||||
marginTop: SPACING[4],
|
||||
marginBottom: SPACING[7],
|
||||
},
|
||||
heroHeader: {
|
||||
heroGreetingRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
streakBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.FULL,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${BRAND.PRIMARY}30`,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
heroName: {
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
heroSubtitle: {
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
heroTitle: {
|
||||
flex: 1,
|
||||
marginRight: SPACING[3],
|
||||
},
|
||||
profileButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
// Sections
|
||||
section: {
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
sectionHeader: {
|
||||
// Quick Stats Row
|
||||
quickStatsRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[7],
|
||||
},
|
||||
quickStatPill: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Categories
|
||||
categoriesScroll: {
|
||||
gap: SPACING[2],
|
||||
paddingRight: SPACING[4],
|
||||
},
|
||||
categoryChip: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
paddingVertical: SPACING[4],
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
},
|
||||
categoryChipActive: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: `${BRAND.PRIMARY}30`,
|
||||
borderCurve: 'continuous',
|
||||
gap: SPACING[1],
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
|
||||
// Workouts Scroll
|
||||
workoutsScroll: {
|
||||
gap: SPACING[3],
|
||||
paddingRight: SPACING[4],
|
||||
// Assessment Card
|
||||
assessmentCard: {
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
padding: SPACING[5],
|
||||
marginBottom: SPACING[8],
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glassStrong,
|
||||
borderCurve: 'continuous',
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
|
||||
// Collections Grid
|
||||
collectionsGrid: {
|
||||
assessmentContent: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
alignItems: 'center',
|
||||
},
|
||||
assessmentIconCircle: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
overflow: 'hidden',
|
||||
borderCurve: 'continuous',
|
||||
marginRight: SPACING[4],
|
||||
},
|
||||
assessmentIconInner: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
assessmentText: {
|
||||
flex: 1,
|
||||
},
|
||||
assessmentArrow: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: `${BRAND.PRIMARY}18`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
|
||||
// Programs Section
|
||||
programsSection: {
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
sectionHeader: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
sectionSubtitle: {
|
||||
marginTop: SPACING[1],
|
||||
},
|
||||
|
||||
// Program Card
|
||||
programCard: {
|
||||
borderRadius: RADIUS.XL,
|
||||
marginBottom: SPACING[6],
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glassStrong,
|
||||
borderCurve: 'continuous',
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
accentLine: {
|
||||
height: 2,
|
||||
width: '100%',
|
||||
},
|
||||
programCardContent: {
|
||||
padding: SPACING[5],
|
||||
paddingRight: SPACING[6],
|
||||
},
|
||||
programCardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: SPACING[4],
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
// Gradient icon circle
|
||||
programIconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
overflow: 'hidden',
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
programIconGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
programIconInner: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
programHeaderText: {
|
||||
flex: 1,
|
||||
paddingBottom: SPACING[1],
|
||||
},
|
||||
programTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: RADIUS.FULL,
|
||||
borderWidth: 1,
|
||||
},
|
||||
programTitle: {
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
programDescription: {
|
||||
marginBottom: SPACING[4],
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressContainer: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginBottom: SPACING[2],
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.glass.inset.backgroundColor,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
progressFillWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
|
||||
// Stats as inline meta text
|
||||
programMeta: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Premium CTA Button
|
||||
ctaButtonWrapper: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
ctaButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[5],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
},
|
||||
ctaIcon: {
|
||||
marginLeft: SPACING[2],
|
||||
},
|
||||
|
||||
// Switch Program — glass pill
|
||||
switchProgramButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
gap: SPACING[2],
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[6],
|
||||
marginTop: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.glass.base.backgroundColor,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,52 +8,23 @@ import {
|
||||
View,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Pressable,
|
||||
Switch,
|
||||
Text as RNText,
|
||||
TextStyle,
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import * as Linking from 'expo-linking'
|
||||
import Constants from 'expo-constants'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import { useUserStore } from '@/src/shared/stores'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useUserStore, useActivityStore } from '@/src/shared/stores'
|
||||
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLED TEXT COMPONENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
interface TextProps {
|
||||
children: React.ReactNode
|
||||
style?: TextStyle
|
||||
size?: number
|
||||
weight?: 'normal' | 'bold' | '600' | '700' | '800' | '900'
|
||||
color?: string
|
||||
center?: boolean
|
||||
}
|
||||
|
||||
function Text({ children, style, size, weight, color, center }: TextProps) {
|
||||
const colors = useThemeColors()
|
||||
return (
|
||||
<RNText
|
||||
style={[
|
||||
{
|
||||
fontSize: size ?? 17,
|
||||
fontWeight: weight ?? 'normal',
|
||||
color: color ?? colors.text.primary,
|
||||
textAlign: center ? 'center' : 'left',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</RNText>
|
||||
)
|
||||
}
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
|
||||
import { deleteSyncedData } from '@/src/shared/services/sync'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// COMPONENT: PROFILE SCREEN
|
||||
@@ -69,17 +40,21 @@ export default function ProfileScreen() {
|
||||
const settings = useUserStore((s) => s.settings)
|
||||
const updateSettings = useUserStore((s) => s.updateSettings)
|
||||
const updateProfile = useUserStore((s) => s.updateProfile)
|
||||
const setSyncStatus = useUserStore((s) => s.setSyncStatus)
|
||||
const { restorePurchases, isPremium } = usePurchases()
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
|
||||
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
|
||||
const avatarInitial = profile.name?.[0]?.toUpperCase() || 'U'
|
||||
|
||||
// Mock stats (replace with real data from activityStore when available)
|
||||
const stats = {
|
||||
workouts: 47,
|
||||
streak: 12,
|
||||
calories: 12500,
|
||||
}
|
||||
// Real stats from activity store
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
const stats = useMemo(() => ({
|
||||
workouts: history.length,
|
||||
streak: streak.current,
|
||||
calories: history.reduce((sum, r) => sum + (r.calories ?? 0), 0),
|
||||
}), [history, streak])
|
||||
|
||||
const handleSignOut = () => {
|
||||
updateProfile({
|
||||
@@ -95,6 +70,14 @@ export default function ProfileScreen() {
|
||||
await restorePurchases()
|
||||
}
|
||||
|
||||
const handleDeleteData = async () => {
|
||||
const result = await deleteSyncedData()
|
||||
if (result.success) {
|
||||
setSyncStatus('unsynced', null)
|
||||
setShowDeleteModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReminderToggle = async (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
const granted = await requestNotificationPermissions()
|
||||
@@ -136,24 +119,24 @@ export default function ProfileScreen() {
|
||||
<View style={styles.headerContainer}>
|
||||
{/* Avatar with gradient background */}
|
||||
<View style={styles.avatarContainer}>
|
||||
<Text size={48} weight="bold" color="#FFFFFF">
|
||||
<StyledText size={48} weight="bold" color="#FFFFFF">
|
||||
{avatarInitial}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Name & Plan */}
|
||||
<View style={styles.nameContainer}>
|
||||
<Text size={22} weight="600" center>
|
||||
<StyledText size={22} weight="semibold" style={{ textAlign: 'center' }}>
|
||||
{profile.name || t('profile.guest')}
|
||||
</Text>
|
||||
</StyledText>
|
||||
<View style={styles.planContainer}>
|
||||
<Text size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
|
||||
<StyledText size={15} color={isPremium ? BRAND.PRIMARY : colors.text.tertiary}>
|
||||
{planLabel}
|
||||
</Text>
|
||||
</StyledText>
|
||||
{isPremium && (
|
||||
<Text size={12} color={BRAND.PRIMARY}>
|
||||
<StyledText size={12} color={BRAND.PRIMARY}>
|
||||
✓
|
||||
</Text>
|
||||
</StyledText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -161,28 +144,28 @@ export default function ProfileScreen() {
|
||||
{/* Stats Row */}
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
|
||||
🔥 {stats.workouts}
|
||||
</Text>
|
||||
<Text size={12} color={colors.text.tertiary} center>
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsWorkouts')}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
|
||||
📅 {stats.streak}
|
||||
</Text>
|
||||
<Text size={12} color={colors.text.tertiary} center>
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsStreak')}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text size={20} weight="bold" color={BRAND.PRIMARY} center>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY} style={{ textAlign: 'center' }}>
|
||||
⚡️ {Math.round(stats.calories / 1000)}k
|
||||
</Text>
|
||||
<Text size={12} color={colors.text.tertiary} center>
|
||||
</StyledText>
|
||||
<StyledText size={12} color={colors.text.tertiary} style={{ textAlign: 'center' }}>
|
||||
{t('profile.statsCalories')}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -193,32 +176,32 @@ export default function ProfileScreen() {
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{!isPremium && (
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
style={styles.premiumContainer}
|
||||
onPress={() => router.push('/paywall')}
|
||||
>
|
||||
<View style={styles.premiumContent}>
|
||||
<Text size={17} weight="600" color={BRAND.PRIMARY}>
|
||||
<StyledText size={17} weight="semibold" color={BRAND.PRIMARY}>
|
||||
✨ {t('profile.upgradeTitle')}
|
||||
</Text>
|
||||
<Text size={15} color={colors.text.tertiary} style={{ marginTop: 4 }}>
|
||||
</StyledText>
|
||||
<StyledText size={15} color={colors.text.tertiary} style={{ marginTop: SPACING[1] }}>
|
||||
{t('profile.upgradeDescription')}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
<Text size={15} color={BRAND.PRIMARY} style={{ marginTop: 12 }}>
|
||||
<StyledText size={15} color={BRAND.PRIMARY} style={{ marginTop: SPACING[3] }}>
|
||||
{t('profile.learnMore')} →
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
WORKOUT SETTINGS
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionWorkout')}</Text>
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionWorkout')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.hapticFeedback')}</Text>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.hapticFeedback')}</StyledText>
|
||||
<Switch
|
||||
value={settings.haptics}
|
||||
onValueChange={(v) => updateSettings({ haptics: v })}
|
||||
@@ -227,7 +210,7 @@ export default function ProfileScreen() {
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.soundEffects')}</Text>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.soundEffects')}</StyledText>
|
||||
<Switch
|
||||
value={settings.soundEffects}
|
||||
onValueChange={(v) => updateSettings({ soundEffects: v })}
|
||||
@@ -236,7 +219,7 @@ export default function ProfileScreen() {
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowLast]}>
|
||||
<Text style={styles.rowLabel}>{t('profile.voiceCoaching')}</Text>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.voiceCoaching')}</StyledText>
|
||||
<Switch
|
||||
value={settings.voiceCoaching}
|
||||
onValueChange={(v) => updateSettings({ voiceCoaching: v })}
|
||||
@@ -249,10 +232,10 @@ export default function ProfileScreen() {
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
NOTIFICATIONS
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionNotifications')}</Text>
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionNotifications')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.dailyReminders')}</Text>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.dailyReminders')}</StyledText>
|
||||
<Switch
|
||||
value={settings.reminders}
|
||||
onValueChange={handleReminderToggle}
|
||||
@@ -262,37 +245,59 @@ export default function ProfileScreen() {
|
||||
</View>
|
||||
{settings.reminders && (
|
||||
<View style={styles.rowTime}>
|
||||
<Text style={styles.rowLabel}>{t('profile.reminderTime')}</Text>
|
||||
<Text style={styles.rowValue}>{settings.reminderTime}</Text>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.reminderTime')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>{settings.reminderTime}</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
PERSONALIZATION (PREMIUM ONLY)
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{isPremium && (
|
||||
<>
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionPersonalization')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={[styles.row, styles.rowLast]}>
|
||||
<StyledText style={styles.rowLabel}>
|
||||
{profile.syncStatus === 'synced' ? t('profile.personalizationEnabled') : t('profile.personalizationDisabled')}
|
||||
</StyledText>
|
||||
<StyledText
|
||||
size={14}
|
||||
color={profile.syncStatus === 'synced' ? BRAND.SUCCESS : colors.text.tertiary}
|
||||
>
|
||||
{profile.syncStatus === 'synced' ? '✓' : '○'}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
ABOUT
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionAbout')}</Text>
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionAbout')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>{t('profile.version')}</Text>
|
||||
<Text style={styles.rowValue}>{appVersion}</Text>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.version')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>{appVersion}</StyledText>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.row} onPress={handleRateApp}>
|
||||
<Text style={styles.rowLabel}>{t('profile.rateApp')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.row} onPress={handleContactUs}>
|
||||
<Text style={styles.rowLabel}>{t('profile.contactUs')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.row} onPress={handleFAQ}>
|
||||
<Text style={styles.rowLabel}>{t('profile.faq')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
|
||||
<Text style={styles.rowLabel}>{t('profile.privacyPolicy')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<Pressable style={styles.row} onPress={handleRateApp}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.rateApp')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
<Pressable style={styles.row} onPress={handleContactUs}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.contactUs')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
<Pressable style={styles.row} onPress={handleFAQ}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.faq')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.row, styles.rowLast]} onPress={handlePrivacyPolicy}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.privacyPolicy')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* ════════════════════════════════════════════════════════════════════
|
||||
@@ -300,12 +305,12 @@ export default function ProfileScreen() {
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
{isPremium && (
|
||||
<>
|
||||
<Text style={styles.sectionHeader}>{t('profile.sectionAccount')}</Text>
|
||||
<StyledText style={styles.sectionHeader}>{t('profile.sectionAccount')}</StyledText>
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity style={[styles.row, styles.rowLast]} onPress={handleRestore}>
|
||||
<Text style={styles.rowLabel}>{t('profile.restorePurchases')}</Text>
|
||||
<Text style={styles.rowValue}>›</Text>
|
||||
</TouchableOpacity>
|
||||
<Pressable style={[styles.row, styles.rowLast]} onPress={handleRestore}>
|
||||
<StyledText style={styles.rowLabel}>{t('profile.restorePurchases')}</StyledText>
|
||||
<StyledText style={styles.rowValue}>›</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
@@ -314,11 +319,18 @@ export default function ProfileScreen() {
|
||||
SIGN OUT
|
||||
═══════════════════════════════════════════════════════════════════ */}
|
||||
<View style={[styles.section, styles.signOutSection]}>
|
||||
<TouchableOpacity style={styles.button} onPress={handleSignOut}>
|
||||
<Text style={styles.destructive}>{t('profile.signOut')}</Text>
|
||||
</TouchableOpacity>
|
||||
<Pressable style={styles.button} onPress={handleSignOut}>
|
||||
<StyledText style={styles.destructive}>{t('profile.signOut')}</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Data Deletion Modal */}
|
||||
<DataDeletionModal
|
||||
visible={showDeleteModal}
|
||||
onDelete={handleDeleteData}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -340,10 +352,10 @@ function createStyles(colors: ThemeColors) {
|
||||
flexGrow: 1,
|
||||
},
|
||||
section: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 20,
|
||||
marginHorizontal: SPACING[4],
|
||||
marginTop: SPACING[5],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: 10,
|
||||
borderRadius: RADIUS.MD,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionHeader: {
|
||||
@@ -351,14 +363,14 @@ function createStyles(colors: ThemeColors) {
|
||||
fontWeight: '600',
|
||||
color: colors.text.tertiary,
|
||||
textTransform: 'uppercase',
|
||||
marginLeft: 32,
|
||||
marginTop: 20,
|
||||
marginBottom: 8,
|
||||
marginLeft: SPACING[8],
|
||||
marginTop: SPACING[5],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: SPACING[6],
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 90,
|
||||
@@ -367,44 +379,40 @@ function createStyles(colors: ThemeColors) {
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: BRAND.PRIMARY,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
boxShadow: `0 4px 20px ${BRAND.PRIMARY}80`,
|
||||
},
|
||||
nameContainer: {
|
||||
marginTop: 16,
|
||||
marginTop: SPACING[4],
|
||||
alignItems: 'center',
|
||||
},
|
||||
planContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
gap: 4,
|
||||
marginTop: SPACING[1],
|
||||
gap: SPACING[1],
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: 16,
|
||||
gap: 32,
|
||||
marginTop: SPACING[4],
|
||||
gap: SPACING[8],
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
premiumContainer: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: SPACING[4],
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
premiumContent: {
|
||||
gap: 4,
|
||||
gap: SPACING[1],
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
borderBottomWidth: 0.5,
|
||||
borderBottomColor: colors.border.glassLight,
|
||||
},
|
||||
@@ -423,13 +431,13 @@ function createStyles(colors: ThemeColors) {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: colors.border.glassLight,
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 14,
|
||||
paddingVertical: SPACING[3] + 2,
|
||||
alignItems: 'center',
|
||||
},
|
||||
destructive: {
|
||||
@@ -437,7 +445,7 @@ function createStyles(colors: ThemeColors) {
|
||||
color: BRAND.DANGER,
|
||||
},
|
||||
signOutSection: {
|
||||
marginTop: 20,
|
||||
marginTop: SPACING[5],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
/**
|
||||
* TabataFit Workouts Screen
|
||||
* Premium workout browser — scrollable category pills, trainers, workout grid
|
||||
*/
|
||||
|
||||
import { useState, useRef, useMemo } from 'react'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Dimensions, Animated } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { WORKOUTS } from '@/src/shared/data'
|
||||
import { useTranslatedCategories, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window')
|
||||
const CARD_WIDTH = (SCREEN_WIDTH - LAYOUT.SCREEN_PADDING * 2 - SPACING[3]) / 2
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CATEGORY PILL
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function CategoryPill({
|
||||
label,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<Pressable
|
||||
style={[styles.pill, selected && styles.pillSelected]}
|
||||
onPress={onPress}
|
||||
>
|
||||
{selected && (
|
||||
<LinearGradient
|
||||
colors={GRADIENTS.CTA}
|
||||
style={[StyleSheet.absoluteFill, { borderRadius: 20 }]}
|
||||
/>
|
||||
)}
|
||||
<StyledText
|
||||
size={14}
|
||||
weight={selected ? 'semibold' : 'regular'}
|
||||
color={selected ? '#FFFFFF' : colors.text.tertiary}
|
||||
>
|
||||
{label}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// WORKOUT CARD
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function WorkoutCard({
|
||||
title,
|
||||
duration,
|
||||
level,
|
||||
levelLabel,
|
||||
onPress,
|
||||
}: {
|
||||
title: string
|
||||
duration: number
|
||||
level: string
|
||||
levelLabel: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
return (
|
||||
<Pressable style={styles.workoutCard} onPress={onPress}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
|
||||
{/* Subtle gradient accent at top */}
|
||||
<LinearGradient
|
||||
colors={[levelColor(level, colors) + '30', 'transparent']}
|
||||
style={styles.cardGradient}
|
||||
/>
|
||||
|
||||
{/* Duration badge */}
|
||||
<View style={styles.durationBadge}>
|
||||
<Ionicons name="time-outline" size={10} color={colors.text.secondary} />
|
||||
<StyledText size={11} weight="semibold" color={colors.text.secondary} style={{ marginLeft: 3 }}>
|
||||
{duration + ' min'}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Play button */}
|
||||
<View style={styles.playArea}>
|
||||
<View style={styles.playCircle}>
|
||||
<Ionicons name="play" size={18} color={colors.text.primary} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={14} weight="semibold" color={colors.text.primary} numberOfLines={2}>
|
||||
{title}
|
||||
</StyledText>
|
||||
<View style={styles.levelRow}>
|
||||
<View style={[styles.levelDot, { backgroundColor: levelColor(level, colors) }]} />
|
||||
<StyledText size={11} color={colors.text.tertiary}>
|
||||
{levelLabel}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function levelColor(level: string, colors: ThemeColors): string {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'beginner': return BRAND.SUCCESS
|
||||
case 'intermediate': return BRAND.SECONDARY
|
||||
case 'advanced': return BRAND.DANGER
|
||||
default: return colors.text.tertiary
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function WorkoutsScreen() {
|
||||
const { t } = useTranslation()
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const [selectedCategory, setSelectedCategory] = useState('all')
|
||||
const categories = useTranslatedCategories()
|
||||
|
||||
const filteredWorkouts = selectedCategory === 'all'
|
||||
? WORKOUTS
|
||||
: WORKOUTS.filter(w => w.category === selectedCategory)
|
||||
|
||||
const translatedFiltered = useTranslatedWorkouts(filteredWorkouts)
|
||||
|
||||
const handleWorkoutPress = (id: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${id}`)
|
||||
}
|
||||
|
||||
const selectedLabel = categories.find(c => c.id === selectedCategory)?.label ?? t('screens:workouts.allWorkouts')
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<StyledText size={34} weight="bold" color={colors.text.primary}>
|
||||
{t('screens:workouts.title')}
|
||||
</StyledText>
|
||||
<StyledText size={15} color={colors.text.tertiary}>
|
||||
{t('screens:workouts.available', { count: WORKOUTS.length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Category Pills — horizontal scroll, no truncation */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.pillsRow}
|
||||
style={styles.pillsScroll}
|
||||
>
|
||||
{categories.map((cat) => (
|
||||
<CategoryPill
|
||||
key={cat.id}
|
||||
label={cat.label}
|
||||
selected={selectedCategory === cat.id}
|
||||
onPress={() => {
|
||||
haptics.selection()
|
||||
setSelectedCategory(cat.id)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* Workouts Grid */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<StyledText size={20} weight="semibold" color={colors.text.primary}>
|
||||
{selectedCategory === 'all' ? t('screens:workouts.allWorkouts') : selectedLabel}
|
||||
</StyledText>
|
||||
{selectedCategory !== 'all' && (
|
||||
<Pressable onPress={() => { haptics.buttonTap(); router.push(`/workout/category/${selectedCategory}`) }}>
|
||||
<StyledText size={14} color={BRAND.PRIMARY} weight="medium">{t('seeAll')}</StyledText>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.workoutsGrid}>
|
||||
{translatedFiltered.map((workout) => (
|
||||
<WorkoutCard
|
||||
key={workout.id}
|
||||
title={workout.title}
|
||||
duration={workout.duration}
|
||||
level={workout.level}
|
||||
levelLabel={t(`levels.${workout.level.toLowerCase()}`)}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Pills
|
||||
pillsScroll: {
|
||||
marginHorizontal: -LAYOUT.SCREEN_PADDING,
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
pillsRow: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
gap: SPACING[2],
|
||||
},
|
||||
pill: {
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glassLight,
|
||||
},
|
||||
pillSelected: {
|
||||
borderColor: BRAND.PRIMARY,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
// Section
|
||||
section: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Workouts Grid
|
||||
workoutsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutCard: {
|
||||
width: CARD_WIDTH,
|
||||
height: 190,
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.bg.overlay2,
|
||||
},
|
||||
cardGradient: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 80,
|
||||
},
|
||||
durationBadge: {
|
||||
position: 'absolute',
|
||||
top: SPACING[3],
|
||||
right: SPACING[3],
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 3,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
playArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 64,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
playCircle: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.border.glassStrong,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.25)',
|
||||
},
|
||||
workoutInfo: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: SPACING[3],
|
||||
paddingTop: SPACING[2],
|
||||
},
|
||||
levelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 3,
|
||||
gap: 5,
|
||||
},
|
||||
levelDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -119,6 +119,13 @@ function RootLayoutInner() {
|
||||
<Stack.Screen
|
||||
name="workout/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
|
||||
headerShadowVisible: false,
|
||||
headerTitle: '',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
@@ -128,12 +135,31 @@ function RootLayoutInner() {
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="program/[id]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerBlurEffect: colors.colorScheme === 'dark' ? 'dark' : 'light',
|
||||
headerShadowVisible: false,
|
||||
headerTitle: '',
|
||||
headerBackButtonDisplayMode: 'minimal',
|
||||
headerTintColor: colors.colorScheme === 'dark' ? '#FFFFFF' : '#000000',
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="collection/[id]"
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="assessment"
|
||||
options={{
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="player/[id]"
|
||||
options={{
|
||||
@@ -147,6 +173,15 @@ function RootLayoutInner() {
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="explore-filters"
|
||||
options={{
|
||||
presentation: 'formSheet',
|
||||
headerShown: false,
|
||||
sheetGrabberVisible: true,
|
||||
sheetAllowedDetents: [0.5],
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</View>
|
||||
</QueryClientProvider>
|
||||
|
||||
448
app/assessment.tsx
Normal file
448
app/assessment.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* TabataFit Assessment Screen
|
||||
* Initial movement assessment to personalize experience
|
||||
*/
|
||||
|
||||
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useProgramStore } from '@/src/shared/stores'
|
||||
import { ASSESSMENT_WORKOUT } from '@/src/shared/data/programs'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
|
||||
const FONTS = {
|
||||
LARGE_TITLE: 28,
|
||||
TITLE: 24,
|
||||
HEADLINE: 17,
|
||||
BODY: 16,
|
||||
CAPTION: 13,
|
||||
}
|
||||
|
||||
export default function AssessmentScreen() {
|
||||
const { t } = useTranslation('screens')
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
const [showIntro, setShowIntro] = useState(true)
|
||||
const skipAssessment = useProgramStore((s) => s.skipAssessment)
|
||||
const completeAssessment = useProgramStore((s) => s.completeAssessment)
|
||||
|
||||
const handleSkip = () => {
|
||||
haptics.buttonTap()
|
||||
skipAssessment()
|
||||
router.back()
|
||||
}
|
||||
|
||||
const handleStart = () => {
|
||||
haptics.buttonTap()
|
||||
setShowIntro(false)
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
haptics.workoutComplete()
|
||||
completeAssessment({
|
||||
completedAt: new Date().toISOString(),
|
||||
exercisesCompleted: ASSESSMENT_WORKOUT.exercises.map(e => e.name),
|
||||
})
|
||||
router.back()
|
||||
}
|
||||
|
||||
if (!showIntro) {
|
||||
// Here we'd show the actual assessment workout player
|
||||
// For now, just show a completion screen
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.backButton} onPress={() => setShowIntro(true)}>
|
||||
<Icon name="arrow.left" size={24} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
<StyledText size={FONTS.TITLE} weight="bold" color={colors.text.primary}>
|
||||
{t('assessment.title')}
|
||||
</StyledText>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
|
||||
>
|
||||
<View style={styles.assessmentContainer}>
|
||||
<View style={styles.exerciseList}>
|
||||
{ASSESSMENT_WORKOUT.exercises.map((exercise, index) => (
|
||||
<View key={exercise.name} style={styles.exerciseItem}>
|
||||
<View style={styles.exerciseNumber}>
|
||||
<StyledText size={14} weight="bold" color={colors.text.primary}>
|
||||
{index + 1}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.exerciseInfo}>
|
||||
<StyledText size={16} weight="semibold" color={colors.text.primary}>
|
||||
{exercise.name}
|
||||
</StyledText>
|
||||
<StyledText size={13} color={colors.text.secondary}>
|
||||
{exercise.duration}s • {exercise.purpose}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.tipsSection}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary} style={styles.tipsTitle}>
|
||||
{t('assessment.tips')}
|
||||
</StyledText>
|
||||
{[1, 2, 3, 4].map((index) => (
|
||||
<View key={index} style={styles.tipItem}>
|
||||
<Icon name="checkmark.circle" size={18} color={BRAND.PRIMARY} />
|
||||
<StyledText size={14} color={colors.text.secondary} style={styles.tipText}>
|
||||
{t(`assessment.tip${index}`)}
|
||||
</StyledText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<Pressable style={styles.ctaButton} onPress={handleComplete}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF3B30']}
|
||||
style={styles.ctaGradient}
|
||||
>
|
||||
<StyledText size={16} weight="bold" color="#FFFFFF">
|
||||
{t('assessment.startAssessment')}
|
||||
</StyledText>
|
||||
<Icon name="play.fill" size={20} color="#FFFFFF" style={styles.ctaIcon} />
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.backButton} onPress={handleSkip}>
|
||||
<Icon name="xmark" size={24} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 120 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Hero */}
|
||||
<View style={styles.heroSection}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Icon name="clipboard" size={48} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
|
||||
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color={colors.text.primary} style={styles.heroTitle}>
|
||||
{t('assessment.welcomeTitle')}
|
||||
</StyledText>
|
||||
|
||||
<StyledText size={FONTS.BODY} color={colors.text.secondary} style={styles.heroDescription}>
|
||||
{t('assessment.welcomeDescription')}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Features */}
|
||||
<View style={styles.featuresSection}>
|
||||
<View style={styles.featureItem}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Icon name="clock" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.featureText}>
|
||||
<StyledText size={16} weight="semibold" color={colors.text.primary}>
|
||||
{ASSESSMENT_WORKOUT.duration} {t('assessment.minutes')}
|
||||
</StyledText>
|
||||
<StyledText size={14} color={colors.text.secondary}>
|
||||
{t('assessment.quickCheck')}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.featureItem}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Icon name="figure.stand" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.featureText}>
|
||||
<StyledText size={16} weight="semibold" color={colors.text.primary}>
|
||||
{ASSESSMENT_WORKOUT.exercises.length} {t('assessment.movements')}
|
||||
</StyledText>
|
||||
<StyledText size={14} color={colors.text.secondary}>
|
||||
{t('assessment.testMovements')}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.featureItem}>
|
||||
<View style={styles.featureIcon}>
|
||||
<Icon name="dumbbell" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.featureText}>
|
||||
<StyledText size={16} weight="semibold" color={colors.text.primary}>
|
||||
{t('assessment.noEquipment')}
|
||||
</StyledText>
|
||||
<StyledText size={14} color={colors.text.secondary}>
|
||||
{t('assessment.justYourBody')}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Benefits */}
|
||||
<View style={styles.benefitsSection}>
|
||||
<StyledText size={FONTS.HEADLINE} weight="semibold" color={colors.text.primary} style={styles.benefitsTitle}>
|
||||
{t('assessment.whatWeCheck')}
|
||||
</StyledText>
|
||||
|
||||
<View style={styles.benefitsList}>
|
||||
<View style={styles.benefitTag}>
|
||||
<StyledText size={13} color={colors.text.primary}>
|
||||
{t('assessment.mobility')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.benefitTag}>
|
||||
<StyledText size={13} color={colors.text.primary}>
|
||||
{t('assessment.strength')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.benefitTag}>
|
||||
<StyledText size={13} color={colors.text.primary}>
|
||||
{t('assessment.stability')}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.benefitTag}>
|
||||
<StyledText size={13} color={colors.text.primary}>
|
||||
{t('assessment.balance')}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<Pressable style={styles.ctaButton} onPress={handleStart}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF3B30']}
|
||||
style={styles.ctaGradient}
|
||||
>
|
||||
<StyledText size={16} weight="bold" color="#FFFFFF">
|
||||
{t('assessment.takeAssessment')}
|
||||
</StyledText>
|
||||
<Icon name="arrow.right" size={20} color="#FFFFFF" style={styles.ctaIcon} />
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.skipButton} onPress={handleSkip}>
|
||||
<StyledText size={15} color={colors.text.tertiary}>
|
||||
{t('assessment.skipForNow')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
|
||||
// Hero
|
||||
heroSection: {
|
||||
alignItems: 'center',
|
||||
marginTop: SPACING[4],
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
iconContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
heroTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
heroDescription: {
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
|
||||
// Features
|
||||
featuresSection: {
|
||||
marginBottom: SPACING[8],
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
padding: SPACING[4],
|
||||
borderRadius: RADIUS.LG,
|
||||
},
|
||||
featureIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: SPACING[3],
|
||||
},
|
||||
featureText: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Benefits
|
||||
benefitsSection: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
benefitsTitle: {
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
benefitsList: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
benefitTag: {
|
||||
backgroundColor: colors.bg.surface,
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border.glass,
|
||||
},
|
||||
|
||||
// Assessment Container
|
||||
assessmentContainer: {
|
||||
marginTop: SPACING[2],
|
||||
},
|
||||
exerciseList: {
|
||||
marginBottom: SPACING[6],
|
||||
},
|
||||
exerciseItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bg.surface,
|
||||
padding: SPACING[4],
|
||||
borderRadius: RADIUS.LG,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
exerciseNumber: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: `${BRAND.PRIMARY}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: SPACING[3],
|
||||
},
|
||||
exerciseInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// Tips
|
||||
tipsSection: {
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
padding: SPACING[5],
|
||||
},
|
||||
tipsTitle: {
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
tipItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
tipText: {
|
||||
marginLeft: SPACING[2],
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
// Bottom Bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.bg.base,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border.glass,
|
||||
},
|
||||
ctaButton: {
|
||||
borderRadius: RADIUS.LG,
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
ctaGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[4],
|
||||
},
|
||||
ctaIcon: {
|
||||
marginLeft: SPACING[2],
|
||||
},
|
||||
skipButton: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[2],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Feb 20, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5541 | 11:52 PM | 🔄 | Converted 4 detail screens to use theme system | ~264 |
|
||||
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
|
||||
</claude-mem-context>
|
||||
@@ -1,23 +1,24 @@
|
||||
/**
|
||||
* TabataFit Collection Detail Screen
|
||||
* Shows collection info + ordered workout list
|
||||
* Shows collection info + list of workouts in that collection
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-native'
|
||||
import { View, StyleSheet, ScrollView, Pressable } from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { getCollectionById, getCollectionWorkouts, COLLECTION_COLORS } from '@/src/shared/data'
|
||||
import { useTranslatedCollections, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { useCollection } from '@/src/shared/hooks/useSupabaseData'
|
||||
import { getWorkoutById } from '@/src/shared/data'
|
||||
import { useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
@@ -32,15 +33,17 @@ export default function CollectionDetailScreen() {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
|
||||
const rawCollection = id ? getCollectionById(id) : null
|
||||
const translatedCollections = useTranslatedCollections(rawCollection ? [rawCollection] : [])
|
||||
const collection = translatedCollections.length > 0 ? translatedCollections[0] : null
|
||||
const rawWorkouts = useMemo(
|
||||
() => id ? getCollectionWorkouts(id).filter((w): w is NonNullable<typeof w> => w != null) : [],
|
||||
[id]
|
||||
)
|
||||
const { data: collection, isLoading } = useCollection(id)
|
||||
|
||||
// Resolve workouts from collection's workoutIds
|
||||
const rawWorkouts = useMemo(() => {
|
||||
if (!collection) return []
|
||||
return collection.workoutIds
|
||||
.map((wId) => getWorkoutById(wId))
|
||||
.filter(Boolean) as NonNullable<ReturnType<typeof getWorkoutById>>[]
|
||||
}, [collection])
|
||||
|
||||
const workouts = useTranslatedWorkouts(rawWorkouts)
|
||||
const collectionColor = COLLECTION_COLORS[id ?? ''] ?? BRAND.PRIMARY
|
||||
|
||||
const handleBack = () => {
|
||||
haptics.selection()
|
||||
@@ -49,89 +52,122 @@ export default function CollectionDetailScreen() {
|
||||
|
||||
const handleWorkoutPress = (workoutId: string) => {
|
||||
haptics.buttonTap()
|
||||
track('collection_workout_tapped', {
|
||||
collection_id: id,
|
||||
workout_id: workoutId,
|
||||
})
|
||||
router.push(`/workout/${workoutId}`)
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top, alignItems: 'center', justifyContent: 'center' }]}>
|
||||
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:collection.notFound')}</RNText>
|
||||
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
|
||||
<StyledText size={17} color={colors.text.tertiary}>Loading...</StyledText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const totalMinutes = workouts.reduce((sum, w) => sum + (w?.duration ?? 0), 0)
|
||||
const totalCalories = workouts.reduce((sum, w) => sum + (w?.calories ?? 0), 0)
|
||||
if (!collection) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centered, { paddingTop: insets.top }]}>
|
||||
<Icon name="folder" size={48} color={colors.text.tertiary} />
|
||||
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
|
||||
Collection not found
|
||||
</StyledText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
<View testID="collection-detail-screen" style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Pressable testID="collection-back-button" onPress={handleBack} style={styles.backButton}>
|
||||
<Icon name="chevron.left" size={24} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary} numberOfLines={1} style={{ flex: 1, textAlign: 'center' }}>
|
||||
{collection.title}
|
||||
</StyledText>
|
||||
<View style={styles.backButton} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Hero Header — on gradient, text stays white */}
|
||||
<View style={styles.hero}>
|
||||
{/* Hero Card */}
|
||||
<View testID="collection-hero" style={styles.heroCard}>
|
||||
<LinearGradient
|
||||
colors={collection.gradient ?? [collectionColor, BRAND.PRIMARY_DARK]}
|
||||
colors={collection.gradient ?? [BRAND.PRIMARY, '#FF3B30']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<Pressable onPress={handleBack} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.heroContent}>
|
||||
<RNText style={styles.heroIcon}>{collection.icon}</RNText>
|
||||
<StyledText size={28} weight="bold" color="#FFFFFF">{collection.title}</StyledText>
|
||||
<StyledText size={15} color="rgba(255, 255, 255, 0.8)">{collection.description}</StyledText>
|
||||
|
||||
<View style={styles.heroStats}>
|
||||
<View style={styles.heroStat}>
|
||||
<Ionicons name="fitness" size={14} color="#FFFFFF" />
|
||||
<StyledText size={13} color="#FFFFFF">{t('plurals.workout', { count: workouts.length })}</StyledText>
|
||||
</View>
|
||||
<View style={styles.heroStat}>
|
||||
<Ionicons name="time" size={14} color="#FFFFFF" />
|
||||
<StyledText size={13} color="#FFFFFF">{t('screens:collection.minTotal', { count: totalMinutes })}</StyledText>
|
||||
</View>
|
||||
<View style={styles.heroStat}>
|
||||
<Ionicons name="flame" size={14} color="#FFFFFF" />
|
||||
<StyledText size={13} color="#FFFFFF">{t('units.calUnit', { count: totalCalories })}</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
<StyledText size={48} color="#FFFFFF" style={styles.heroIcon}>
|
||||
{collection.icon}
|
||||
</StyledText>
|
||||
<StyledText size={28} weight="bold" color="#FFFFFF">
|
||||
{collection.title}
|
||||
</StyledText>
|
||||
<StyledText size={15} color="rgba(255,255,255,0.8)" style={{ marginTop: SPACING[1] }}>
|
||||
{collection.description}
|
||||
</StyledText>
|
||||
<StyledText size={13} weight="semibold" color="rgba(255,255,255,0.6)" style={{ marginTop: SPACING[2] }}>
|
||||
{t('plurals.workout', { count: workouts.length })}
|
||||
</StyledText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Workout List — on base bg, use theme tokens */}
|
||||
<View style={styles.workoutList}>
|
||||
{workouts.map((workout, index) => {
|
||||
if (!workout) return null
|
||||
return (
|
||||
<Pressable
|
||||
key={workout.id}
|
||||
style={styles.workoutCard}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
>
|
||||
<View style={[styles.workoutNumber, { backgroundColor: `${collectionColor}20` }]}>
|
||||
<RNText style={[styles.workoutNumberText, { color: collectionColor }]}>{index + 1}</RNText>
|
||||
</View>
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={17} weight="semibold" color={colors.text.primary}>{workout.title}</StyledText>
|
||||
<StyledText size={13} color={colors.text.tertiary}>
|
||||
{t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.workoutMeta}>
|
||||
<StyledText size={13} color={BRAND.PRIMARY}>{t('units.calUnit', { count: workout.calories })}</StyledText>
|
||||
<Ionicons name="play-circle" size={28} color={collectionColor} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
{/* Workout List */}
|
||||
<StyledText
|
||||
size={20}
|
||||
weight="bold"
|
||||
color={colors.text.primary}
|
||||
style={{ marginTop: SPACING[6], marginBottom: SPACING[3] }}
|
||||
>
|
||||
{t('screens:explore.workouts')}
|
||||
</StyledText>
|
||||
|
||||
{workouts.map((workout) => (
|
||||
<Pressable
|
||||
key={workout.id}
|
||||
testID={`collection-workout-${workout.id}`}
|
||||
style={styles.workoutCard}
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
>
|
||||
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
|
||||
<Icon name="flame.fill" size={20} color="#FFFFFF" />
|
||||
</View>
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={17} weight="semibold" color={colors.text.primary}>
|
||||
{workout.title}
|
||||
</StyledText>
|
||||
<StyledText size={13} color={colors.text.tertiary}>
|
||||
{t('durationLevel', {
|
||||
duration: workout.duration,
|
||||
level: t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`),
|
||||
})}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.workoutMeta}>
|
||||
<StyledText size={13} color={BRAND.PRIMARY}>
|
||||
{t('units.calUnit', { count: workout.calories })}
|
||||
</StyledText>
|
||||
<Icon name="chevron.right" size={16} color={colors.text.tertiary} />
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
{workouts.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Icon name="dumbbell" size={48} color={colors.text.tertiary} />
|
||||
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
|
||||
No workouts in this collection
|
||||
</StyledText>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
@@ -143,49 +179,42 @@ function createStyles(colors: ThemeColors) {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
centered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scrollContent: {},
|
||||
|
||||
// Hero
|
||||
hero: {
|
||||
height: 260,
|
||||
overflow: 'hidden',
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
backButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: SPACING[3],
|
||||
},
|
||||
heroCard: {
|
||||
height: 200,
|
||||
borderRadius: RADIUS.XL,
|
||||
overflow: 'hidden',
|
||||
...colors.shadow.lg,
|
||||
},
|
||||
heroContent: {
|
||||
position: 'absolute',
|
||||
bottom: SPACING[5],
|
||||
left: SPACING[5],
|
||||
right: SPACING[5],
|
||||
flex: 1,
|
||||
padding: SPACING[5],
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
heroIcon: {
|
||||
fontSize: 40,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
heroStats: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[4],
|
||||
marginTop: SPACING[3],
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
heroStat: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[1],
|
||||
},
|
||||
|
||||
// Workout List
|
||||
workoutList: {
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[4],
|
||||
gap: SPACING[2],
|
||||
},
|
||||
workoutCard: {
|
||||
flexDirection: 'row',
|
||||
@@ -194,19 +223,16 @@ function createStyles(colors: ThemeColors) {
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
marginBottom: SPACING[2],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutNumber: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
workoutAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
workoutNumberText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
workoutInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
@@ -215,5 +241,10 @@ function createStyles(colors: ThemeColors) {
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: SPACING[12],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Celebration with real data from activity store
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useMemo } from 'react'
|
||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
@@ -15,17 +15,19 @@ import {
|
||||
} from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useActivityStore } from '@/src/shared/stores'
|
||||
import { getWorkoutById, getPopularWorkouts } from '@/src/shared/data'
|
||||
import { useActivityStore, useUserStore } from '@/src/shared/stores'
|
||||
import { getWorkoutById, getPopularWorkouts, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
|
||||
import { useTranslatedWorkout, useTranslatedWorkouts } from '@/src/shared/data/useTranslatedData'
|
||||
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
|
||||
import { enableSync } from '@/src/shared/services/sync'
|
||||
import type { WorkoutSessionData } from '@/src/shared/types'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
@@ -47,7 +49,7 @@ function SecondaryButton({
|
||||
}: {
|
||||
onPress: () => void
|
||||
children: React.ReactNode
|
||||
icon?: keyof typeof Ionicons.glyphMap
|
||||
icon?: IconName
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
@@ -77,7 +79,7 @@ function SecondaryButton({
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Animated.View style={[styles.secondaryButton, { transform: [{ scale: scaleAnim }] }]}>
|
||||
{icon && <Ionicons name={icon} size={18} color={colors.text.primary} style={styles.buttonIcon} />}
|
||||
{icon && <Icon name={icon} size={18} tintColor={colors.text.primary} style={styles.buttonIcon} />}
|
||||
<RNText style={styles.secondaryButtonText}>{children}</RNText>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
@@ -92,6 +94,7 @@ function PrimaryButton({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
const isDark = colors.colorScheme === 'dark'
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const scaleAnim = useRef(new Animated.Value(1)).current
|
||||
|
||||
@@ -118,14 +121,15 @@ function PrimaryButton({
|
||||
onPressOut={handlePressOut}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Animated.View style={[styles.primaryButton, { transform: [{ scale: scaleAnim }] }]}>
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<RNText style={styles.primaryButtonText}>{children}</RNText>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.primaryButton,
|
||||
{ backgroundColor: isDark ? '#FFFFFF' : '#000000', transform: [{ scale: scaleAnim }] },
|
||||
]}
|
||||
>
|
||||
<RNText style={[styles.primaryButtonText, { color: isDark ? '#000000' : '#FFFFFF' }]}>
|
||||
{children}
|
||||
</RNText>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)
|
||||
@@ -187,11 +191,13 @@ function StatCard({
|
||||
value,
|
||||
label,
|
||||
icon,
|
||||
accentColor,
|
||||
delay = 0,
|
||||
}: {
|
||||
value: string | number
|
||||
label: string
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
icon: IconName
|
||||
accentColor: string
|
||||
delay?: number
|
||||
}) {
|
||||
const colors = useThemeColors()
|
||||
@@ -212,14 +218,14 @@ function StatCard({
|
||||
return (
|
||||
<Animated.View style={[styles.statCard, { transform: [{ scale: scaleAnim }] }]}>
|
||||
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<Ionicons name={icon} size={24} color={BRAND.PRIMARY} />
|
||||
<Icon name={icon} size={24} tintColor={accentColor} />
|
||||
<RNText style={styles.statValue}>{value}</RNText>
|
||||
<RNText style={styles.statLabel}>{label}</RNText>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function BurnBarResult({ percentile }: { percentile: number }) {
|
||||
function BurnBarResult({ percentile, accentColor }: { percentile: number; accentColor: string }) {
|
||||
const { t } = useTranslation()
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
@@ -242,9 +248,9 @@ function BurnBarResult({ percentile }: { percentile: number }) {
|
||||
return (
|
||||
<View style={styles.burnBarContainer}>
|
||||
<RNText style={styles.burnBarTitle}>{t('screens:complete.burnBar')}</RNText>
|
||||
<RNText style={styles.burnBarResult}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
|
||||
<RNText style={[styles.burnBarResult, { color: accentColor }]}>{t('screens:complete.burnBarResult', { percentile })}</RNText>
|
||||
<View style={styles.burnBarTrack}>
|
||||
<Animated.View style={[styles.burnBarFill, { width: barWidth }]} />
|
||||
<Animated.View style={[styles.burnBarFill, { width: barWidth, backgroundColor: accentColor }]} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
@@ -266,10 +272,16 @@ export default function WorkoutCompleteScreen() {
|
||||
|
||||
const rawWorkout = getWorkoutById(id ?? '1')
|
||||
const workout = useTranslatedWorkout(rawWorkout)
|
||||
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
|
||||
const trainerColor = getWorkoutAccentColor(id ?? '1')
|
||||
const streak = useActivityStore((s) => s.streak)
|
||||
const history = useActivityStore((s) => s.history)
|
||||
const recentWorkouts = history.slice(0, 1)
|
||||
|
||||
// Sync consent modal state
|
||||
const [showSyncPrompt, setShowSyncPrompt] = useState(false)
|
||||
const { profile, setSyncStatus } = useUserStore()
|
||||
|
||||
// Get the most recent result for this workout
|
||||
const latestResult = recentWorkouts[0]
|
||||
const resultCalories = latestResult?.calories ?? workout?.calories ?? 45
|
||||
@@ -299,6 +311,59 @@ export default function WorkoutCompleteScreen() {
|
||||
router.push(`/workout/${workoutId}`)
|
||||
}
|
||||
|
||||
// Fire celebration haptic on mount
|
||||
useEffect(() => {
|
||||
haptics.workoutComplete()
|
||||
}, [])
|
||||
|
||||
// Check if we should show sync prompt (after first workout for premium users)
|
||||
useEffect(() => {
|
||||
if (profile.syncStatus === 'prompt-pending') {
|
||||
// Wait a moment for the user to see their results first
|
||||
const timer = setTimeout(() => {
|
||||
setShowSyncPrompt(true)
|
||||
}, 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [profile.syncStatus])
|
||||
|
||||
const handleSyncAccept = async () => {
|
||||
setShowSyncPrompt(false)
|
||||
|
||||
// Prepare data for sync
|
||||
const profileData = {
|
||||
name: profile.name,
|
||||
fitnessLevel: profile.fitnessLevel,
|
||||
goal: profile.goal,
|
||||
weeklyFrequency: profile.weeklyFrequency,
|
||||
barriers: profile.barriers,
|
||||
onboardingCompletedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Get all workout history for retroactive sync
|
||||
const workoutHistory: WorkoutSessionData[] = history.map((w) => ({
|
||||
workoutId: w.workoutId,
|
||||
completedAt: new Date(w.completedAt).toISOString(),
|
||||
durationSeconds: w.durationMinutes * 60,
|
||||
caloriesBurned: w.calories,
|
||||
}))
|
||||
|
||||
// Enable sync
|
||||
const result = await enableSync(profileData, workoutHistory)
|
||||
|
||||
if (result.success) {
|
||||
setSyncStatus('synced', result.userId || null)
|
||||
} else {
|
||||
// Show error - sync failed
|
||||
setSyncStatus('never-synced')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncDecline = () => {
|
||||
setShowSyncPrompt(false)
|
||||
setSyncStatus('never-synced') // Reset so we don't ask again
|
||||
}
|
||||
|
||||
// Simulate percentile
|
||||
const burnBarPercentile = Math.min(95, Math.max(40, Math.round((resultCalories / (workout?.calories ?? 45)) * 70)))
|
||||
|
||||
@@ -318,20 +383,20 @@ export default function WorkoutCompleteScreen() {
|
||||
|
||||
{/* Stats Grid */}
|
||||
<View style={styles.statsGrid}>
|
||||
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame" delay={100} />
|
||||
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="time" delay={200} />
|
||||
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark-circle" delay={300} />
|
||||
<StatCard value={resultCalories} label={t('screens:complete.caloriesLabel')} icon="flame.fill" accentColor={trainerColor} delay={100} />
|
||||
<StatCard value={resultMinutes} label={t('screens:complete.minutesLabel')} icon="clock.fill" accentColor={trainerColor} delay={200} />
|
||||
<StatCard value="100%" label={t('screens:complete.completeLabel')} icon="checkmark.circle.fill" accentColor={trainerColor} delay={300} />
|
||||
</View>
|
||||
|
||||
{/* Burn Bar */}
|
||||
<BurnBarResult percentile={burnBarPercentile} />
|
||||
<BurnBarResult percentile={burnBarPercentile} accentColor={trainerColor} />
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Streak */}
|
||||
<View style={styles.streakSection}>
|
||||
<View style={styles.streakBadge}>
|
||||
<Ionicons name="flame" size={32} color={BRAND.PRIMARY} />
|
||||
<View style={[styles.streakBadge, { backgroundColor: trainerColor + '26' }]}>
|
||||
<Icon name="flame.fill" size={32} tintColor={trainerColor} />
|
||||
</View>
|
||||
<View style={styles.streakInfo}>
|
||||
<RNText style={styles.streakTitle}>{t('screens:complete.streakTitle', { count: streak.current })}</RNText>
|
||||
@@ -343,7 +408,7 @@ export default function WorkoutCompleteScreen() {
|
||||
|
||||
{/* Share Button */}
|
||||
<View style={styles.shareSection}>
|
||||
<SecondaryButton onPress={handleShare} icon="share-outline">
|
||||
<SecondaryButton onPress={handleShare} icon="square.and.arrow.up">
|
||||
{t('screens:complete.shareWorkout')}
|
||||
</SecondaryButton>
|
||||
</View>
|
||||
@@ -361,12 +426,8 @@ export default function WorkoutCompleteScreen() {
|
||||
style={styles.recommendedCard}
|
||||
>
|
||||
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
|
||||
<View style={styles.recommendedThumb}>
|
||||
<LinearGradient
|
||||
colors={[BRAND.PRIMARY, BRAND.PRIMARY_LIGHT]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
<Ionicons name="flame" size={24} color="#FFFFFF" />
|
||||
<View style={[styles.recommendedThumb, { backgroundColor: trainerColor + '20' }]}>
|
||||
<Icon name="flame.fill" size={24} tintColor={trainerColor} />
|
||||
</View>
|
||||
<RNText style={styles.recommendedTitleText} numberOfLines={1}>{w.title}</RNText>
|
||||
<RNText style={styles.recommendedDurationText}>{t('units.minUnit', { count: w.duration })}</RNText>
|
||||
@@ -385,6 +446,13 @@ export default function WorkoutCompleteScreen() {
|
||||
</PrimaryButton>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Sync Consent Modal */}
|
||||
<SyncConsentModal
|
||||
visible={showSyncPrompt}
|
||||
onAccept={handleSyncAccept}
|
||||
onDecline={handleSyncDecline}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -433,7 +501,6 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
primaryButtonText: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
},
|
||||
buttonIcon: {
|
||||
@@ -521,7 +588,6 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
burnBarResult: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: BRAND.PRIMARY,
|
||||
marginTop: SPACING[1],
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
@@ -533,7 +599,6 @@ function createStyles(colors: ThemeColors) {
|
||||
},
|
||||
burnBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
borderRadius: 4,
|
||||
},
|
||||
|
||||
@@ -555,7 +620,6 @@ function createStyles(colors: ThemeColors) {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
|
||||
222
app/explore-filters.tsx
Normal file
222
app/explore-filters.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* TabataFit Explore Filters Sheet
|
||||
* Form-sheet modal for Level + Equipment filter selection.
|
||||
* Reads/writes from useExploreFilterStore.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Text,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useExploreFilterStore } from '@/src/shared/stores'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import type { WorkoutLevel } from '@/src/shared/types'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CONSTANTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const ALL_LEVELS: (WorkoutLevel | 'all')[] = ['all', 'Beginner', 'Intermediate', 'Advanced']
|
||||
|
||||
const LEVEL_TRANSLATION_KEYS: Record<WorkoutLevel | 'all', string> = {
|
||||
all: 'all',
|
||||
Beginner: 'beginner',
|
||||
Intermediate: 'intermediate',
|
||||
Advanced: 'advanced',
|
||||
}
|
||||
|
||||
const EQUIPMENT_TRANSLATION_KEYS: Record<string, string> = {
|
||||
none: 'none',
|
||||
dumbbells: 'dumbbells',
|
||||
band: 'band',
|
||||
mat: 'mat',
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CHOICE CHIP
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ChoiceChip({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
colors,
|
||||
}: {
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onPress: () => void
|
||||
colors: ThemeColors
|
||||
}) {
|
||||
const haptics = useHaptics()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
chipStyles.chip,
|
||||
{
|
||||
backgroundColor: isSelected ? BRAND.PRIMARY + '20' : colors.glass.base.backgroundColor,
|
||||
borderColor: isSelected ? BRAND.PRIMARY : colors.border.glass,
|
||||
},
|
||||
]}
|
||||
onPress={() => {
|
||||
haptics.selection()
|
||||
onPress()
|
||||
}}
|
||||
>
|
||||
{isSelected && (
|
||||
<Icon name="checkmark.circle.fill" size={16} color={BRAND.PRIMARY} style={{ marginRight: SPACING[1] }} />
|
||||
)}
|
||||
<StyledText
|
||||
size={15}
|
||||
weight={isSelected ? 'semibold' : 'medium'}
|
||||
color={isSelected ? BRAND.PRIMARY : colors.text.secondary}
|
||||
>
|
||||
{label}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const chipStyles = StyleSheet.create({
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingVertical: SPACING[3],
|
||||
borderRadius: RADIUS.LG,
|
||||
borderWidth: 1,
|
||||
borderCurve: 'continuous',
|
||||
marginRight: SPACING[2],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function ExploreFiltersScreen() {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
// ── Store state ────────────────────────────────────────────────────────
|
||||
const level = useExploreFilterStore((s) => s.level)
|
||||
const equipment = useExploreFilterStore((s) => s.equipment)
|
||||
const equipmentOptions = useExploreFilterStore((s) => s.equipmentOptions)
|
||||
const setLevel = useExploreFilterStore((s) => s.setLevel)
|
||||
const setEquipment = useExploreFilterStore((s) => s.setEquipment)
|
||||
const resetFilters = useExploreFilterStore((s) => s.resetFilters)
|
||||
|
||||
const hasActiveFilters = level !== 'all' || equipment !== 'all'
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────
|
||||
const handleReset = useCallback(() => {
|
||||
haptics.selection()
|
||||
resetFilters()
|
||||
}, [haptics, resetFilters])
|
||||
|
||||
// ── Equipment label helper ─────────────────────────────────────────────
|
||||
const getEquipmentLabel = useCallback(
|
||||
(equip: string) => {
|
||||
if (equip === 'all') return t('screens:explore.allEquipment')
|
||||
const key = EQUIPMENT_TRANSLATION_KEYS[equip]
|
||||
if (key) return t(`screens:explore.equipmentOptions.${key}`)
|
||||
return equip.charAt(0).toUpperCase() + equip.slice(1)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
// ── Level label helper ─────────────────────────────────────────────────
|
||||
const getLevelLabel = useCallback(
|
||||
(lvl: WorkoutLevel | 'all') => {
|
||||
if (lvl === 'all') return t('common:categories.all')
|
||||
const key = LEVEL_TRANSLATION_KEYS[lvl]
|
||||
return t(`common:levels.${key}`)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.bg.base }}>
|
||||
{/* ── Title row ─────────────────────────────────────────────── */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingHorizontal: LAYOUT.SCREEN_PADDING, paddingTop: SPACING[5], paddingBottom: SPACING[4] }}>
|
||||
<StyledText size={17} weight="semibold" color={colors.text.primary}>
|
||||
{t('screens:explore.filters')}
|
||||
</StyledText>
|
||||
{hasActiveFilters && (
|
||||
<Pressable onPress={handleReset} hitSlop={8} style={{ position: 'absolute', right: LAYOUT.SCREEN_PADDING }}>
|
||||
<Text style={{ fontSize: 17, color: BRAND.PRIMARY }}>
|
||||
{t('screens:explore.resetFilters')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── Filter sections ───────────────────────────────────────── */}
|
||||
<View style={{ flex: 1, paddingHorizontal: LAYOUT.SCREEN_PADDING }}>
|
||||
{/* Level */}
|
||||
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={{ marginBottom: SPACING[2], letterSpacing: 0.5 }}>
|
||||
{t('screens:explore.filterLevel').toUpperCase()}
|
||||
</StyledText>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginBottom: SPACING[3] }}>
|
||||
{ALL_LEVELS.map((lvl) => (
|
||||
<ChoiceChip
|
||||
key={lvl}
|
||||
label={getLevelLabel(lvl)}
|
||||
isSelected={level === lvl}
|
||||
onPress={() => setLevel(lvl)}
|
||||
colors={colors}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Equipment */}
|
||||
<StyledText size={13} weight="semibold" color={colors.text.tertiary} style={{ marginBottom: SPACING[2], letterSpacing: 0.5 }}>
|
||||
{t('screens:explore.filterEquipment').toUpperCase()}
|
||||
</StyledText>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
||||
{equipmentOptions.map((equip) => (
|
||||
<ChoiceChip
|
||||
key={equip}
|
||||
label={getEquipmentLabel(equip)}
|
||||
isSelected={equipment === equip}
|
||||
onPress={() => setEquipment(equip)}
|
||||
colors={colors}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ── Apply Button ──────────────────────────────────────────── */}
|
||||
<View style={{ paddingHorizontal: LAYOUT.SCREEN_PADDING, paddingTop: SPACING[3], paddingBottom: Math.max(insets.bottom, SPACING[4]), borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border.glass }}>
|
||||
<Pressable
|
||||
style={{ height: 52, borderRadius: RADIUS.LG, backgroundColor: BRAND.PRIMARY, alignItems: 'center', justifyContent: 'center', borderCurve: 'continuous' }}
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
router.back()
|
||||
}}
|
||||
>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
{t('screens:explore.applyFilters')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
import { Alert } from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -85,7 +85,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
|
||||
marginBottom: SPACING[8],
|
||||
}}
|
||||
>
|
||||
<Ionicons name="time" size={80} color={BRAND.PRIMARY} />
|
||||
<Icon name="clock.fill" size={80} color={BRAND.PRIMARY} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View style={{ opacity: textOpacity, alignItems: 'center' }}>
|
||||
@@ -116,6 +116,7 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
|
||||
<View style={styles.bottomAction}>
|
||||
<Pressable
|
||||
style={styles.ctaButton}
|
||||
testID="onboarding-problem-cta"
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
onNext()
|
||||
@@ -135,10 +136,10 @@ function ProblemScreen({ onNext }: { onNext: () => void }) {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const BARRIERS = [
|
||||
{ id: 'no-time', labelKey: 'onboarding.empathy.noTime' as const, icon: 'time-outline' as const },
|
||||
{ id: 'low-motivation', labelKey: 'onboarding.empathy.lowMotivation' as const, icon: 'battery-dead-outline' as const },
|
||||
{ id: 'no-knowledge', labelKey: 'onboarding.empathy.noKnowledge' as const, icon: 'help-circle-outline' as const },
|
||||
{ id: 'no-gym', labelKey: 'onboarding.empathy.noGym' as const, icon: 'home-outline' as const },
|
||||
{ id: 'no-time', labelKey: 'onboarding.empathy.noTime' as const, icon: 'clock' as const },
|
||||
{ id: 'low-motivation', labelKey: 'onboarding.empathy.lowMotivation' as const, icon: 'battery.0percent' as const },
|
||||
{ id: 'no-knowledge', labelKey: 'onboarding.empathy.noKnowledge' as const, icon: 'questionmark.circle' as const },
|
||||
{ id: 'no-gym', labelKey: 'onboarding.empathy.noGym' as const, icon: 'house' as const },
|
||||
]
|
||||
|
||||
function EmpathyScreen({
|
||||
@@ -179,13 +180,14 @@ function EmpathyScreen({
|
||||
return (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
testID={`barrier-${item.id}`}
|
||||
style={[
|
||||
styles.barrierCard,
|
||||
selected && styles.barrierCardSelected,
|
||||
]}
|
||||
onPress={() => toggleBarrier(item.id)}
|
||||
>
|
||||
<Ionicons
|
||||
<Icon
|
||||
name={item.icon}
|
||||
size={28}
|
||||
color={selected ? BRAND.PRIMARY : colors.text.tertiary}
|
||||
@@ -206,6 +208,7 @@ function EmpathyScreen({
|
||||
<View style={styles.bottomAction}>
|
||||
<Pressable
|
||||
style={[styles.ctaButton, barriers.length === 0 && styles.ctaButtonDisabled]}
|
||||
testID="onboarding-empathy-continue"
|
||||
onPress={() => {
|
||||
if (barriers.length > 0) {
|
||||
haptics.buttonTap()
|
||||
@@ -350,6 +353,7 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
|
||||
<View style={styles.bottomAction}>
|
||||
<Pressable
|
||||
style={styles.ctaButton}
|
||||
testID="onboarding-solution-cta"
|
||||
onPress={() => {
|
||||
haptics.buttonTap()
|
||||
onNext()
|
||||
@@ -369,10 +373,10 @@ function SolutionScreen({ onNext }: { onNext: () => void }) {
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const WOW_FEATURES = [
|
||||
{ icon: 'timer-outline' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
|
||||
{ icon: 'barbell-outline' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' },
|
||||
{ icon: 'mic-outline' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' },
|
||||
{ icon: 'trending-up-outline' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' },
|
||||
{ icon: 'timer' as const, iconColor: BRAND.PRIMARY, titleKey: 'onboarding.wow.card1Title', subtitleKey: 'onboarding.wow.card1Subtitle' },
|
||||
{ icon: 'dumbbell' as const, iconColor: PHASE.REST, titleKey: 'onboarding.wow.card2Title', subtitleKey: 'onboarding.wow.card2Subtitle' },
|
||||
{ icon: 'mic' as const, iconColor: PHASE.PREP, titleKey: 'onboarding.wow.card3Title', subtitleKey: 'onboarding.wow.card3Subtitle' },
|
||||
{ icon: 'arrow.up.right' as const, iconColor: PHASE.COMPLETE, titleKey: 'onboarding.wow.card4Title', subtitleKey: 'onboarding.wow.card4Subtitle' },
|
||||
] as const
|
||||
|
||||
function WowScreen({ onNext }: { onNext: () => void }) {
|
||||
@@ -449,7 +453,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
|
||||
]}
|
||||
>
|
||||
<View style={[wowStyles.iconCircle, { backgroundColor: `${feature.iconColor}26` }]}>
|
||||
<Ionicons name={feature.icon} size={22} color={feature.iconColor} />
|
||||
<Icon name={feature.icon} size={22} color={feature.iconColor} />
|
||||
</View>
|
||||
<View style={wowStyles.textCol}>
|
||||
<StyledText size={17} weight="semibold" color={colors.text.primary}>
|
||||
@@ -467,6 +471,7 @@ function WowScreen({ onNext }: { onNext: () => void }) {
|
||||
<Animated.View style={[styles.bottomAction, { opacity: ctaOpacity }]}>
|
||||
<Pressable
|
||||
style={styles.ctaButton}
|
||||
testID="onboarding-wow-cta"
|
||||
onPress={() => {
|
||||
if (ctaReady) {
|
||||
haptics.buttonTap()
|
||||
@@ -556,6 +561,7 @@ function PersonalizationScreen({
|
||||
placeholderTextColor={colors.text.hint}
|
||||
autoCapitalize="words"
|
||||
autoCorrect={false}
|
||||
testID="name-input"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -568,6 +574,7 @@ function PersonalizationScreen({
|
||||
{LEVELS.map((item) => (
|
||||
<Pressable
|
||||
key={item.value}
|
||||
testID={`level-${item.value}`}
|
||||
style={[
|
||||
styles.segmentButton,
|
||||
level === item.value && styles.segmentButtonActive,
|
||||
@@ -598,6 +605,7 @@ function PersonalizationScreen({
|
||||
{GOALS.map((item) => (
|
||||
<Pressable
|
||||
key={item.value}
|
||||
testID={`goal-${item.value}`}
|
||||
style={[
|
||||
styles.segmentButton,
|
||||
goal === item.value && styles.segmentButtonActive,
|
||||
@@ -628,6 +636,7 @@ function PersonalizationScreen({
|
||||
{FREQUENCIES.map((item) => (
|
||||
<Pressable
|
||||
key={item.value}
|
||||
testID={`frequency-${item.value}x`}
|
||||
style={[
|
||||
styles.segmentButton,
|
||||
frequency === item.value && styles.segmentButtonActive,
|
||||
@@ -658,6 +667,7 @@ function PersonalizationScreen({
|
||||
<View style={{ marginTop: SPACING[8] }}>
|
||||
<Pressable
|
||||
style={[styles.ctaButton, !name.trim() && styles.ctaButtonDisabled]}
|
||||
testID="onboarding-personalization-continue"
|
||||
onPress={() => {
|
||||
if (name.trim()) {
|
||||
haptics.buttonTap()
|
||||
@@ -812,7 +822,7 @@ function PaywallScreen({
|
||||
key={featureKey}
|
||||
style={[styles.featureRow, { opacity: featureAnims[i] }]}
|
||||
>
|
||||
<Ionicons name="checkmark-circle" size={22} color={BRAND.SUCCESS} />
|
||||
<Icon name="checkmark.circle.fill" size={22} color={BRAND.SUCCESS} />
|
||||
<StyledText
|
||||
size={16}
|
||||
color={colors.text.primary}
|
||||
@@ -828,6 +838,7 @@ function PaywallScreen({
|
||||
<View style={styles.pricingCards}>
|
||||
{/* Annual */}
|
||||
<Pressable
|
||||
testID="plan-yearly"
|
||||
style={[
|
||||
styles.pricingCard,
|
||||
selectedPlan === 'premium-yearly' && styles.pricingCardSelected,
|
||||
@@ -852,6 +863,7 @@ function PaywallScreen({
|
||||
|
||||
{/* Monthly */}
|
||||
<Pressable
|
||||
testID="plan-monthly"
|
||||
style={[
|
||||
styles.pricingCard,
|
||||
selectedPlan === 'premium-monthly' && styles.pricingCardSelected,
|
||||
@@ -870,6 +882,7 @@ function PaywallScreen({
|
||||
{/* CTA */}
|
||||
<Pressable
|
||||
style={[styles.trialButton, isPurchasing && styles.ctaButtonDisabled]}
|
||||
testID="subscribe-button"
|
||||
onPress={handlePurchase}
|
||||
disabled={isPurchasing}
|
||||
>
|
||||
@@ -886,17 +899,21 @@ function PaywallScreen({
|
||||
</View>
|
||||
|
||||
{/* Restore Purchases */}
|
||||
<Pressable style={styles.restoreButton} onPress={handleRestore}>
|
||||
<Pressable style={styles.restoreButton} onPress={handleRestore} testID="restore-purchases">
|
||||
<StyledText size={14} color={colors.text.hint}>
|
||||
{t('onboarding.paywall.restorePurchases')}
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
|
||||
{/* Skip */}
|
||||
<Pressable style={styles.skipButton} onPress={() => {
|
||||
track('onboarding_paywall_skipped')
|
||||
onSkip()
|
||||
}}>
|
||||
<Pressable
|
||||
style={styles.skipButton}
|
||||
testID="skip-paywall"
|
||||
onPress={() => {
|
||||
track('onboarding_paywall_skipped')
|
||||
onSkip()
|
||||
}}
|
||||
>
|
||||
<StyledText size={14} color={colors.text.hint}>
|
||||
{t('onboarding.paywall.skipButton')}
|
||||
</StyledText>
|
||||
@@ -1019,6 +1036,15 @@ export default function OnboardingScreen() {
|
||||
setStep(next)
|
||||
}, [step, barriers, name, level, goal, frequency])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
if (step > 1) {
|
||||
const prev = step - 1
|
||||
stepStartTime.current = Date.now()
|
||||
track('onboarding_step_back', { from_step: step, to_step: prev })
|
||||
setStep(prev)
|
||||
}
|
||||
}, [step])
|
||||
|
||||
const renderStep = () => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
@@ -1062,7 +1088,7 @@ export default function OnboardingScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingStep step={step} totalSteps={TOTAL_STEPS}>
|
||||
<OnboardingStep step={step} totalSteps={TOTAL_STEPS} onBack={prevStep}>
|
||||
{renderStep()}
|
||||
</OnboardingStep>
|
||||
)
|
||||
@@ -1241,6 +1267,7 @@ function createStyles(colors: ThemeColors) {
|
||||
flex: 1,
|
||||
paddingVertical: SPACING[5],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: RADIUS.GLASS_CARD,
|
||||
...colors.glass.base,
|
||||
},
|
||||
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Text,
|
||||
} from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon, type IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics, usePurchases } from '@/src/shared/hooks'
|
||||
import { StyledText } from '@/src/shared/components/StyledText'
|
||||
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { SPACING } from '@/src/shared/constants/spacing'
|
||||
@@ -27,13 +27,13 @@ import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
// FEATURES LIST
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const PREMIUM_FEATURES = [
|
||||
{ icon: 'musical-notes', key: 'music' },
|
||||
const PREMIUM_FEATURES: { icon: IconName; key: string }[] = [
|
||||
{ icon: 'music.note.list', key: 'music' },
|
||||
{ icon: 'infinity', key: 'workouts' },
|
||||
{ icon: 'stats-chart', key: 'stats' },
|
||||
{ icon: 'flame', key: 'calories' },
|
||||
{ icon: 'notifications', key: 'reminders' },
|
||||
{ icon: 'close-circle', key: 'ads' },
|
||||
{ icon: 'chart.bar.fill', key: 'stats' },
|
||||
{ icon: 'flame.fill', key: 'calories' },
|
||||
{ icon: 'bell.fill', key: 'reminders' },
|
||||
{ icon: 'xmark.circle.fill', key: 'ads' },
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -93,23 +93,23 @@ function PlanCard({
|
||||
>
|
||||
{savings && (
|
||||
<View style={styles.savingsBadge}>
|
||||
<Text style={styles.savingsText}>{savings}</Text>
|
||||
<StyledText size={10} weight="bold" color={colors.text.primary}>{savings}</StyledText>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.planInfo}>
|
||||
<Text style={[styles.planTitle, { color: colors.text.primary }]}>
|
||||
<StyledText size={16} weight="semibold" color={colors.text.primary}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={[styles.planPeriod, { color: colors.text.tertiary }]}>
|
||||
</StyledText>
|
||||
<StyledText size={13} color={colors.text.tertiary} style={{ marginTop: 2 }}>
|
||||
{period}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
<Text style={[styles.planPrice, { color: BRAND.PRIMARY }]}>
|
||||
<StyledText size={20} weight="bold" color={BRAND.PRIMARY}>
|
||||
{price}
|
||||
</Text>
|
||||
</StyledText>
|
||||
{isSelected && (
|
||||
<View style={styles.checkmark}>
|
||||
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
|
||||
<Icon name="checkmark.circle.fill" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -196,7 +196,7 @@ export default function PaywallScreen() {
|
||||
<View style={[styles.container, { paddingTop: insets.top }]}>
|
||||
{/* Close Button */}
|
||||
<Pressable style={[styles.closeButton, { top: insets.top + SPACING[2] }]} onPress={handleClose}>
|
||||
<Ionicons name="close" size={28} color={colors.text.secondary} />
|
||||
<Icon name="xmark" size={28} color={colors.text.secondary} />
|
||||
</Pressable>
|
||||
|
||||
<ScrollView
|
||||
@@ -209,8 +209,12 @@ export default function PaywallScreen() {
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>TabataFit+</Text>
|
||||
<Text style={styles.subtitle}>{t('paywall.subtitle')}</Text>
|
||||
<StyledText size={32} weight="bold" color={colors.text.primary} style={{ textAlign: 'center' }}>
|
||||
TabataFit+
|
||||
</StyledText>
|
||||
<StyledText size={16} color={colors.text.secondary} style={{ textAlign: 'center', marginTop: SPACING[2] }}>
|
||||
{t('paywall.subtitle')}
|
||||
</StyledText>
|
||||
</View>
|
||||
|
||||
{/* Features Grid */}
|
||||
@@ -218,11 +222,11 @@ export default function PaywallScreen() {
|
||||
{PREMIUM_FEATURES.map((feature) => (
|
||||
<View key={feature.key} style={styles.featureItem}>
|
||||
<View style={[styles.featureIcon, { backgroundColor: colors.glass.tinted.backgroundColor }]}>
|
||||
<Ionicons name={feature.icon as any} size={22} color={BRAND.PRIMARY} />
|
||||
<Icon name={feature.icon} size={22} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<Text style={[styles.featureText, { color: colors.text.secondary }]}>
|
||||
<StyledText size={13} color={colors.text.secondary} style={{ textAlign: 'center' }}>
|
||||
{t(`paywall.features.${feature.key}`)}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -252,9 +256,9 @@ export default function PaywallScreen() {
|
||||
|
||||
{/* Price Note */}
|
||||
{selectedPlan === 'annual' && (
|
||||
<Text style={[styles.priceNote, { color: colors.text.tertiary }]}>
|
||||
<StyledText size={13} color={colors.text.tertiary} style={{ textAlign: 'center', marginTop: SPACING[3] }}>
|
||||
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
|
||||
</Text>
|
||||
</StyledText>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
@@ -269,23 +273,23 @@ export default function PaywallScreen() {
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.ctaGradient}
|
||||
>
|
||||
<Text style={[styles.ctaText, { color: colors.text.primary }]}>
|
||||
{isLoading ? t('paywall.processing') : t('paywall.subscribe')}
|
||||
</Text>
|
||||
<StyledText size={17} weight="semibold" color="#FFFFFF">
|
||||
{isLoading ? t('paywall.processing') : t('paywall.trialCta')}
|
||||
</StyledText>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
|
||||
{/* Restore & Terms */}
|
||||
<View style={styles.footer}>
|
||||
<Pressable onPress={handleRestore}>
|
||||
<Text style={[styles.restoreText, { color: colors.text.tertiary }]}>
|
||||
<StyledText size={14} color={colors.text.tertiary}>
|
||||
{t('paywall.restore')}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</Pressable>
|
||||
|
||||
<Text style={[styles.termsText, { color: colors.text.tertiary }]}>
|
||||
<StyledText size={11} color={colors.text.tertiary} style={{ textAlign: 'center', lineHeight: 18, paddingHorizontal: SPACING[4] }}>
|
||||
{t('paywall.terms')}
|
||||
</Text>
|
||||
</StyledText>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import React from 'react'
|
||||
import { View, ScrollView, StyleSheet, Text, Pressable } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
@@ -30,7 +30,7 @@ export default function PrivacyPolicyScreen() {
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Pressable style={styles.backButton} onPress={handleClose}>
|
||||
<Ionicons name="chevron-back" size={28} color={darkColors.text.primary} />
|
||||
<Icon name="chevron.left" size={28} color={darkColors.text.primary} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle}>{t('privacy.title')}</Text>
|
||||
<View style={{ width: 44 }} />
|
||||
|
||||
592
app/program/[id].tsx
Normal file
592
app/program/[id].tsx
Normal file
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* TabataFit Program Detail Screen
|
||||
* Clean scrollable layout — native header, Apple Fitness+ style
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native'
|
||||
import { Stack, useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { useProgramStore } from '@/src/shared/stores'
|
||||
import { PROGRAMS } from '@/src/shared/data/programs'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
import type { ProgramId } from '@/src/shared/types'
|
||||
import type { IconName } from '@/src/shared/components/Icon'
|
||||
|
||||
// Per-program accent colors (matches home screen cards)
|
||||
const PROGRAM_ACCENT: Record<ProgramId, { color: string; icon: IconName }> = {
|
||||
'upper-body': { color: '#FF6B35', icon: 'dumbbell' },
|
||||
'lower-body': { color: '#30D158', icon: 'figure.walk' },
|
||||
'full-body': { color: '#5AC8FA', icon: 'flame' },
|
||||
}
|
||||
|
||||
export default function ProgramDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const programId = id as ProgramId
|
||||
const { t } = useTranslation('screens')
|
||||
const insets = useSafeAreaInsets()
|
||||
const router = useRouter()
|
||||
const haptics = useHaptics()
|
||||
const colors = useThemeColors()
|
||||
const isDark = colors.colorScheme === 'dark'
|
||||
|
||||
const program = PROGRAMS[programId]
|
||||
const accent = PROGRAM_ACCENT[programId] ?? PROGRAM_ACCENT['full-body']
|
||||
const selectProgram = useProgramStore((s) => s.selectProgram)
|
||||
const progress = useProgramStore((s) => s.programsProgress[programId])
|
||||
const isWeekUnlocked = useProgramStore((s) => s.isWeekUnlocked)
|
||||
const getCurrentWorkout = useProgramStore((s) => s.getCurrentWorkout)
|
||||
const completion = useProgramStore((s) => s.getProgramCompletion(programId))
|
||||
|
||||
// CTA entrance animation
|
||||
const ctaAnim = useRef(new Animated.Value(0)).current
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.delay(300),
|
||||
Animated.spring(ctaAnim, {
|
||||
toValue: 1,
|
||||
...SPRING.GENTLE,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (program) {
|
||||
track('program_detail_viewed', {
|
||||
program_id: programId,
|
||||
program_title: program.title,
|
||||
})
|
||||
}
|
||||
}, [programId])
|
||||
|
||||
if (!program) {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerTitle: '' }} />
|
||||
<View style={[s.container, s.centered, { backgroundColor: colors.bg.base }]}>
|
||||
<RNText style={[TYPOGRAPHY.BODY, { color: colors.text.primary }]}>
|
||||
{t('programs.notFound', { defaultValue: 'Program not found' })}
|
||||
</RNText>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const handleStartProgram = () => {
|
||||
haptics.phaseChange()
|
||||
selectProgram(programId)
|
||||
const currentWorkout = getCurrentWorkout(programId)
|
||||
if (currentWorkout) {
|
||||
router.push(`/workout/${currentWorkout.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorkoutPress = (workoutId: string) => {
|
||||
haptics.buttonTap()
|
||||
router.push(`/workout/${workoutId}`)
|
||||
}
|
||||
|
||||
const hasStarted = progress.completedWorkoutIds.length > 0
|
||||
const ctaBg = isDark ? '#FFFFFF' : '#000000'
|
||||
const ctaTextColor = isDark ? '#000000' : '#FFFFFF'
|
||||
|
||||
const ctaLabel = hasStarted
|
||||
? progress.isProgramCompleted
|
||||
? t('programs.restartProgram')
|
||||
: t('programs.continueTraining')
|
||||
: t('programs.startProgram')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ headerTitle: '' }} />
|
||||
|
||||
<View style={[s.container, { backgroundColor: colors.bg.base }]}>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={[
|
||||
s.scrollContent,
|
||||
{ paddingBottom: insets.bottom + 100 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Icon + Title */}
|
||||
<View style={s.titleRow}>
|
||||
<View style={[s.programIcon, { backgroundColor: accent.color + '18' }]}>
|
||||
<Icon name={accent.icon} size={22} tintColor={accent.color} />
|
||||
</View>
|
||||
<View style={s.titleContent}>
|
||||
<RNText selectable style={[s.title, { color: colors.text.primary }]}>
|
||||
{program.title}
|
||||
</RNText>
|
||||
<RNText style={[s.subtitle, { color: colors.text.tertiary }]}>
|
||||
{program.durationWeeks} {t('programs.weeks')} · {program.totalWorkouts} {t('programs.workouts')}
|
||||
</RNText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Description */}
|
||||
<RNText style={[s.description, { color: colors.text.secondary }]}>
|
||||
{program.description}
|
||||
</RNText>
|
||||
|
||||
{/* Stats Card */}
|
||||
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
|
||||
<View style={s.statsRow}>
|
||||
<View style={s.statItem}>
|
||||
<RNText style={[s.statValue, { color: accent.color }]}>
|
||||
{program.durationWeeks}
|
||||
</RNText>
|
||||
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
|
||||
{t('programs.weeks')}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.statDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={s.statItem}>
|
||||
<RNText style={[s.statValue, { color: accent.color }]}>
|
||||
{program.totalWorkouts}
|
||||
</RNText>
|
||||
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
|
||||
{t('programs.workouts')}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.statDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={s.statItem}>
|
||||
<RNText style={[s.statValue, { color: accent.color }]}>
|
||||
4
|
||||
</RNText>
|
||||
<RNText style={[s.statLabel, { color: colors.text.tertiary }]}>
|
||||
{t('programs.minutes')}
|
||||
</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Equipment & Focus */}
|
||||
<View style={s.tagsSection}>
|
||||
{program.equipment.required.length > 0 && (
|
||||
<>
|
||||
<RNText style={[s.tagSectionLabel, { color: colors.text.secondary }]}>
|
||||
{t('programs.equipment')}
|
||||
</RNText>
|
||||
<View style={s.tagRow}>
|
||||
{program.equipment.required.map((item) => (
|
||||
<View key={item} style={[s.tag, { backgroundColor: colors.bg.surface }]}>
|
||||
<RNText style={[s.tagText, { color: colors.text.primary }]}>
|
||||
{item}
|
||||
</RNText>
|
||||
</View>
|
||||
))}
|
||||
{program.equipment.optional.map((item) => (
|
||||
<View key={item} style={[s.tag, { backgroundColor: colors.bg.surface, opacity: 0.7 }]}>
|
||||
<RNText style={[s.tagText, { color: colors.text.tertiary }]}>
|
||||
{item} {t('programs.optional')}
|
||||
</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<RNText style={[s.tagSectionLabel, { color: colors.text.secondary, marginTop: SPACING[4] }]}>
|
||||
{t('programs.focusAreas')}
|
||||
</RNText>
|
||||
<View style={s.tagRow}>
|
||||
{program.focusAreas.map((area) => (
|
||||
<View key={area} style={[s.tag, { backgroundColor: accent.color + '15' }]}>
|
||||
<RNText style={[s.tagText, { color: accent.color }]}>
|
||||
{area}
|
||||
</RNText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
|
||||
|
||||
{/* Progress (if started) */}
|
||||
{hasStarted && (
|
||||
<View style={[s.card, { backgroundColor: colors.bg.surface, marginBottom: SPACING[5] }]}>
|
||||
<View style={s.progressHeader}>
|
||||
<RNText style={[TYPOGRAPHY.HEADLINE, { color: colors.text.primary }]}>
|
||||
{t('programs.yourProgress')}
|
||||
</RNText>
|
||||
<RNText style={[TYPOGRAPHY.HEADLINE, { color: accent.color, fontVariant: ['tabular-nums'] }]}>
|
||||
{completion}%
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.progressTrack, { backgroundColor: colors.border.glassLight }]}>
|
||||
<View
|
||||
style={[
|
||||
s.progressFill,
|
||||
{
|
||||
width: `${completion}%`,
|
||||
backgroundColor: accent.color,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<RNText style={[TYPOGRAPHY.FOOTNOTE, { color: colors.text.tertiary, marginTop: SPACING[2] }]}>
|
||||
{progress.completedWorkoutIds.length} {t('programs.of')} {program.totalWorkouts} {t('programs.workoutsComplete')}
|
||||
</RNText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Training Plan */}
|
||||
<RNText style={[s.sectionTitle, { color: colors.text.primary }]}>
|
||||
{t('programs.trainingPlan')}
|
||||
</RNText>
|
||||
|
||||
{program.weeks.map((week) => {
|
||||
const isUnlocked = isWeekUnlocked(programId, week.weekNumber)
|
||||
const isCurrentWeek = progress.currentWeek === week.weekNumber
|
||||
const weekCompletion = week.workouts.filter((w) =>
|
||||
progress.completedWorkoutIds.includes(w.id)
|
||||
).length
|
||||
|
||||
return (
|
||||
<View
|
||||
key={week.weekNumber}
|
||||
style={[s.card, { backgroundColor: colors.bg.surface, marginBottom: SPACING[3] }]}
|
||||
>
|
||||
{/* Week Header */}
|
||||
<View style={s.weekHeader}>
|
||||
<View style={s.weekTitleRow}>
|
||||
<RNText style={[TYPOGRAPHY.HEADLINE, { color: colors.text.primary, flex: 1 }]}>
|
||||
{week.title}
|
||||
</RNText>
|
||||
{!isUnlocked && (
|
||||
<Icon name="lock.fill" size={16} color={colors.text.hint} />
|
||||
)}
|
||||
{isCurrentWeek && isUnlocked && (
|
||||
<View style={[s.currentBadge, { backgroundColor: accent.color }]}>
|
||||
<RNText style={[s.currentBadgeText, { color: '#FFFFFF' }]}>
|
||||
{t('programs.current')}
|
||||
</RNText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<RNText style={[TYPOGRAPHY.FOOTNOTE, { color: colors.text.secondary, marginTop: 2 }]}>
|
||||
{week.description}
|
||||
</RNText>
|
||||
{weekCompletion > 0 && (
|
||||
<RNText style={[TYPOGRAPHY.CAPTION_1, { color: colors.text.hint, marginTop: SPACING[2] }]}>
|
||||
{weekCompletion}/{week.workouts.length} {t('programs.complete')}
|
||||
</RNText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Week Workouts */}
|
||||
{isUnlocked &&
|
||||
week.workouts.map((workout, index) => {
|
||||
const isCompleted = progress.completedWorkoutIds.includes(workout.id)
|
||||
const isWorkoutLocked =
|
||||
!isCompleted &&
|
||||
index > 0 &&
|
||||
!progress.completedWorkoutIds.includes(week.workouts[index - 1].id) &&
|
||||
week.weekNumber === progress.currentWeek
|
||||
|
||||
return (
|
||||
<View key={workout.id}>
|
||||
<View style={[s.workoutSep, { backgroundColor: colors.border.glassLight }]} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
s.workoutRow,
|
||||
isWorkoutLocked && { opacity: 0.4 },
|
||||
pressed && !isWorkoutLocked && { opacity: 0.6 },
|
||||
]}
|
||||
onPress={() => !isWorkoutLocked && handleWorkoutPress(workout.id)}
|
||||
disabled={isWorkoutLocked}
|
||||
>
|
||||
<View style={s.workoutIcon}>
|
||||
{isCompleted ? (
|
||||
<Icon name="checkmark.circle.fill" size={22} color={BRAND.SUCCESS} />
|
||||
) : isWorkoutLocked ? (
|
||||
<Icon name="lock.fill" size={18} color={colors.text.hint} />
|
||||
) : (
|
||||
<RNText style={[s.workoutIndex, { color: colors.text.tertiary }]}>
|
||||
{index + 1}
|
||||
</RNText>
|
||||
)}
|
||||
</View>
|
||||
<View style={s.workoutInfo}>
|
||||
<RNText
|
||||
style={[
|
||||
TYPOGRAPHY.BODY,
|
||||
{ color: isWorkoutLocked ? colors.text.hint : colors.text.primary },
|
||||
isCompleted && { textDecorationLine: 'line-through' },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workout.title}
|
||||
</RNText>
|
||||
<RNText style={[TYPOGRAPHY.CAPTION_1, { color: colors.text.tertiary }]}>
|
||||
{workout.exercises.length} {t('programs.exercises')} · {workout.duration} {t('programs.min')}
|
||||
</RNText>
|
||||
</View>
|
||||
{!isWorkoutLocked && !isCompleted && (
|
||||
<Icon name="chevron.right" size={16} color={colors.text.hint} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* CTA */}
|
||||
<Animated.View
|
||||
style={[
|
||||
s.bottomBar,
|
||||
{
|
||||
backgroundColor: colors.bg.base,
|
||||
paddingBottom: insets.bottom + SPACING[3],
|
||||
opacity: ctaAnim,
|
||||
transform: [
|
||||
{
|
||||
translateY: ctaAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [30, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
s.ctaButton,
|
||||
{ backgroundColor: ctaBg },
|
||||
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
|
||||
]}
|
||||
onPress={handleStartProgram}
|
||||
>
|
||||
<RNText style={[s.ctaText, { color: ctaTextColor }]}>
|
||||
{ctaLabel}
|
||||
</RNText>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const s = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
centered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[2],
|
||||
},
|
||||
|
||||
// Title
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
programIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: RADIUS.MD,
|
||||
borderCurve: 'continuous',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleContent: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
marginTop: 2,
|
||||
},
|
||||
|
||||
// Description
|
||||
description: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
lineHeight: 24,
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
|
||||
// Card
|
||||
card: {
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
padding: SPACING[4],
|
||||
},
|
||||
|
||||
// Stats
|
||||
statsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
statValue: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
statLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
statDivider: {
|
||||
width: StyleSheet.hairlineWidth,
|
||||
height: 32,
|
||||
},
|
||||
|
||||
// Tags
|
||||
tagsSection: {
|
||||
marginTop: SPACING[5],
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
tagSectionLabel: {
|
||||
...TYPOGRAPHY.FOOTNOTE,
|
||||
fontWeight: '600',
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
tagRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
tag: {
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[1],
|
||||
borderRadius: RADIUS.FULL,
|
||||
},
|
||||
tagText: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
},
|
||||
|
||||
// Separator
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
|
||||
// Progress
|
||||
progressHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
progressTrack: {
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
borderRadius: 3,
|
||||
},
|
||||
|
||||
// Section title
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.TITLE_2,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Week header
|
||||
weekHeader: {
|
||||
marginBottom: SPACING[1],
|
||||
},
|
||||
weekTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
},
|
||||
currentBadge: {
|
||||
paddingHorizontal: SPACING[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: RADIUS.SM,
|
||||
},
|
||||
currentBadgeText: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Workout row
|
||||
workoutSep: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginLeft: SPACING[4] + 28,
|
||||
},
|
||||
workoutRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
gap: SPACING[3],
|
||||
},
|
||||
workoutIcon: {
|
||||
width: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
workoutIndex: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
fontVariant: ['tabular-nums'],
|
||||
fontWeight: '600',
|
||||
},
|
||||
workoutInfo: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
|
||||
// Bottom bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[3],
|
||||
},
|
||||
ctaButton: {
|
||||
height: 54,
|
||||
borderRadius: RADIUS.MD,
|
||||
borderCurve: 'continuous',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
ctaText: {
|
||||
...TYPOGRAPHY.BUTTON_LARGE,
|
||||
},
|
||||
})
|
||||
@@ -1,32 +1,67 @@
|
||||
/**
|
||||
* TabataFit Pre-Workout Detail Screen
|
||||
* Clean modal with workout info
|
||||
* Clean scrollable layout — native header, no hero
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } from 'react-native'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text as RNText,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native'
|
||||
import { Stack } from 'expo-router'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { BlurView } from 'expo-blur'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
|
||||
import { Image } from 'expo-image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Host, Button, HStack } from '@expo/ui/swift-ui'
|
||||
import { glassEffect, padding } from '@expo/ui/swift-ui/modifiers'
|
||||
|
||||
import { useHaptics } from '@/src/shared/hooks'
|
||||
import { usePurchases } from '@/src/shared/hooks/usePurchases'
|
||||
import { useUserStore } from '@/src/shared/stores'
|
||||
import { track } from '@/src/shared/services/analytics'
|
||||
import { getWorkoutById } from '@/src/shared/data'
|
||||
import { canAccessWorkout } from '@/src/shared/services/access'
|
||||
import { getWorkoutById, getTrainerById, getWorkoutAccentColor } from '@/src/shared/data'
|
||||
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
|
||||
|
||||
import { useThemeColors, BRAND } from '@/src/shared/theme'
|
||||
import { useThemeColors } from '@/src/shared/theme'
|
||||
import type { ThemeColors } from '@/src/shared/theme/types'
|
||||
import { TYPOGRAPHY } from '@/src/shared/constants/typography'
|
||||
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
|
||||
import { RADIUS } from '@/src/shared/constants/borderRadius'
|
||||
import { SPRING } from '@/src/shared/constants/animations'
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// MAIN SCREEN
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ─── Save Button (headerRight) ───────────────────────────────────────────────
|
||||
|
||||
function SaveButton({
|
||||
isSaved,
|
||||
onPress,
|
||||
colors,
|
||||
}: {
|
||||
isSaved: boolean
|
||||
onPress: () => void
|
||||
colors: ThemeColors
|
||||
}) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => pressed && { opacity: 0.6 }}
|
||||
>
|
||||
<Icon
|
||||
name={isSaved ? 'heart.fill' : 'heart'}
|
||||
size={22}
|
||||
color={isSaved ? '#FF3B30' : colors.text.primary}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WorkoutDetailScreen() {
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -34,14 +69,31 @@ export default function WorkoutDetailScreen() {
|
||||
const haptics = useHaptics()
|
||||
const { t } = useTranslation()
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const [isSaved, setIsSaved] = useState(false)
|
||||
const savedWorkouts = useUserStore((s) => s.savedWorkouts)
|
||||
const toggleSavedWorkout = useUserStore((s) => s.toggleSavedWorkout)
|
||||
const { isPremium } = usePurchases()
|
||||
|
||||
const colors = useThemeColors()
|
||||
const styles = useMemo(() => createStyles(colors), [colors])
|
||||
const isDark = colors.colorScheme === 'dark'
|
||||
|
||||
const rawWorkout = getWorkoutById(id ?? '1')
|
||||
const workout = useTranslatedWorkout(rawWorkout)
|
||||
const musicVibeLabel = useMusicVibeLabel(rawWorkout?.musicVibe ?? '')
|
||||
const trainer = rawWorkout ? getTrainerById(rawWorkout.trainerId) : undefined
|
||||
const accentColor = getWorkoutAccentColor(id ?? '1')
|
||||
|
||||
// CTA entrance
|
||||
const ctaAnim = useRef(new Animated.Value(0)).current
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.delay(300),
|
||||
Animated.spring(ctaAnim, {
|
||||
toValue: 1,
|
||||
...SPRING.GENTLE,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (workout) {
|
||||
@@ -54,341 +106,438 @@ export default function WorkoutDetailScreen() {
|
||||
}
|
||||
}, [workout?.id])
|
||||
|
||||
const isSaved = savedWorkouts.includes(workout?.id?.toString() ?? '')
|
||||
const toggleSave = () => {
|
||||
if (!workout) return
|
||||
haptics.selection()
|
||||
toggleSavedWorkout(workout.id.toString())
|
||||
}
|
||||
|
||||
if (!workout) {
|
||||
return (
|
||||
<View style={[styles.container, styles.centered]}>
|
||||
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:workout.notFound')}</RNText>
|
||||
</View>
|
||||
<>
|
||||
<Stack.Screen options={{ headerTitle: '' }} />
|
||||
<View style={[s.container, s.centered, { backgroundColor: colors.bg.base }]}>
|
||||
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>
|
||||
{t('screens:workout.notFound')}
|
||||
</RNText>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const isLocked = !canAccessWorkout(workout.id, isPremium)
|
||||
const exerciseCount = workout.exercises?.length || 1
|
||||
const repeatCount = Math.max(1, Math.floor((workout.rounds || exerciseCount) / exerciseCount))
|
||||
|
||||
const handleStartWorkout = () => {
|
||||
if (isLocked) {
|
||||
haptics.buttonTap()
|
||||
track('paywall_triggered', { source: 'workout_detail', workout_id: workout.id })
|
||||
router.push('/paywall')
|
||||
return
|
||||
}
|
||||
haptics.phaseChange()
|
||||
router.push(`/player/${workout.id}`)
|
||||
}
|
||||
|
||||
const toggleSave = () => {
|
||||
haptics.selection()
|
||||
setIsSaved(!isSaved)
|
||||
}
|
||||
const ctaBg = isDark ? '#FFFFFF' : '#000000'
|
||||
const ctaText = isDark ? '#000000' : '#FFFFFF'
|
||||
const ctaLockedBg = isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)'
|
||||
const ctaLockedText = colors.text.primary
|
||||
|
||||
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
|
||||
const equipmentText = workout.equipment.length > 0
|
||||
? workout.equipment.join(' · ')
|
||||
: t('screens:workout.noEquipment', { defaultValue: 'No equipment needed' })
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header with SwiftUI glass button */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + SPACING[3] }]}>
|
||||
<RNText style={styles.headerTitle} numberOfLines={1}>
|
||||
{workout.title}
|
||||
</RNText>
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerRight: () => (
|
||||
<SaveButton isSaved={isSaved} onPress={toggleSave} colors={colors} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* SwiftUI glass button */}
|
||||
<View style={styles.glassButtonContainer}>
|
||||
<Host matchContents useViewportSizeMeasurement colorScheme="dark">
|
||||
<HStack
|
||||
alignment="center"
|
||||
modifiers={[
|
||||
padding({ all: 8 }),
|
||||
glassEffect({ glass: { variant: 'regular' } }),
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onPress={toggleSave}
|
||||
color={isSaved ? '#FF3B30' : '#FFFFFF'}
|
||||
>
|
||||
{isSaved ? '♥' : '♡'}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Host>
|
||||
</View>
|
||||
</View>
|
||||
<View testID="workout-detail-screen" style={[s.container, { backgroundColor: colors.bg.base }]}>
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={[
|
||||
s.scrollContent,
|
||||
{ paddingBottom: insets.bottom + 100 },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Thumbnail / Video Preview */}
|
||||
{rawWorkout?.thumbnailUrl ? (
|
||||
<View style={s.mediaContainer}>
|
||||
<Image
|
||||
source={rawWorkout.thumbnailUrl}
|
||||
style={s.thumbnail}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
</View>
|
||||
) : rawWorkout?.videoUrl ? (
|
||||
<View style={s.mediaContainer}>
|
||||
<VideoPlayer
|
||||
videoUrl={rawWorkout.videoUrl}
|
||||
gradientColors={['#1C1C1E', '#2C2C2E']}
|
||||
mode="preview"
|
||||
isPlaying={false}
|
||||
style={s.thumbnail}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Quick stats */}
|
||||
<View style={styles.quickStats}>
|
||||
<View style={[styles.statBadge, { backgroundColor: 'rgba(255, 107, 53, 0.15)' }]}>
|
||||
<Ionicons name="barbell" size={14} color={BRAND.PRIMARY} />
|
||||
<RNText style={[styles.statBadgeText, { color: BRAND.PRIMARY }]}>
|
||||
{t(`levels.${workout.level.toLowerCase()}`)}
|
||||
{/* Title */}
|
||||
<RNText selectable style={[s.title, { color: colors.text.primary }]}>
|
||||
{workout.title}
|
||||
</RNText>
|
||||
|
||||
{/* Trainer */}
|
||||
{trainer && (
|
||||
<RNText style={[s.trainerName, { color: accentColor }]}>
|
||||
with {trainer.name}
|
||||
</RNText>
|
||||
)}
|
||||
|
||||
{/* Inline metadata */}
|
||||
<View style={s.metaRow}>
|
||||
<View style={s.metaItem}>
|
||||
<Icon name="clock" size={15} tintColor={colors.text.tertiary} />
|
||||
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
|
||||
{workout.duration} {t('units.minUnit', { count: workout.duration })}
|
||||
</RNText>
|
||||
</View>
|
||||
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
|
||||
<View style={s.metaItem}>
|
||||
<Icon name="flame" size={15} tintColor={colors.text.tertiary} />
|
||||
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
|
||||
{workout.calories} {t('units.calUnit', { count: workout.calories })}
|
||||
</RNText>
|
||||
</View>
|
||||
<RNText style={[s.metaDot, { color: colors.text.hint }]}>·</RNText>
|
||||
<RNText style={[s.metaText, { color: colors.text.secondary }]}>
|
||||
{t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`)}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
|
||||
<Ionicons name="time" size={14} color={colors.text.secondary} />
|
||||
<RNText style={styles.statBadgeText}>{t('units.minUnit', { count: workout.duration })}</RNText>
|
||||
</View>
|
||||
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
|
||||
<Ionicons name="flame" size={14} color={colors.text.secondary} />
|
||||
<RNText style={styles.statBadgeText}>{t('units.calUnit', { count: workout.calories })}</RNText>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Equipment */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>{t('screens:workout.whatYoullNeed')}</RNText>
|
||||
{workout.equipment.map((item, index) => (
|
||||
<View key={index} style={styles.equipmentItem}>
|
||||
<Ionicons name="checkmark-circle" size={20} color="#30D158" />
|
||||
<RNText style={styles.equipmentText}>{item}</RNText>
|
||||
{/* Equipment */}
|
||||
<RNText style={[s.equipmentText, { color: colors.text.tertiary }]}>
|
||||
{equipmentText}
|
||||
</RNText>
|
||||
|
||||
{/* Separator */}
|
||||
<View style={[s.separator, { backgroundColor: colors.border.glassLight }]} />
|
||||
|
||||
{/* Timing Card */}
|
||||
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
|
||||
<View style={s.timingRow}>
|
||||
<View style={s.timingItem}>
|
||||
<RNText style={[s.timingValue, { color: accentColor }]}>
|
||||
{workout.prepTime}s
|
||||
</RNText>
|
||||
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
|
||||
{t('screens:workout.prep', { defaultValue: 'Prep' })}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={s.timingItem}>
|
||||
<RNText style={[s.timingValue, { color: accentColor }]}>
|
||||
{workout.workTime}s
|
||||
</RNText>
|
||||
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
|
||||
{t('screens:workout.work', { defaultValue: 'Work' })}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={s.timingItem}>
|
||||
<RNText style={[s.timingValue, { color: accentColor }]}>
|
||||
{workout.restTime}s
|
||||
</RNText>
|
||||
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
|
||||
{t('screens:workout.rest', { defaultValue: 'Rest' })}
|
||||
</RNText>
|
||||
</View>
|
||||
<View style={[s.timingDivider, { backgroundColor: colors.border.glassLight }]} />
|
||||
<View style={s.timingItem}>
|
||||
<RNText style={[s.timingValue, { color: accentColor }]}>
|
||||
{workout.rounds}
|
||||
</RNText>
|
||||
<RNText style={[s.timingLabel, { color: colors.text.tertiary }]}>
|
||||
{t('screens:workout.rounds', { defaultValue: 'Rounds' })}
|
||||
</RNText>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
{/* Exercises Card */}
|
||||
<RNText style={[s.sectionTitle, { color: colors.text.primary }]}>
|
||||
{t('screens:workout.exercises', { count: workout.rounds })}
|
||||
</RNText>
|
||||
|
||||
{/* Exercises */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>{t('screens:workout.exercises', { count: workout.rounds })}</RNText>
|
||||
<View style={styles.exercisesList}>
|
||||
<View style={[s.card, { backgroundColor: colors.bg.surface }]}>
|
||||
{workout.exercises.map((exercise, index) => (
|
||||
<View key={index} style={styles.exerciseRow}>
|
||||
<View style={styles.exerciseNumber}>
|
||||
<RNText style={styles.exerciseNumberText}>{index + 1}</RNText>
|
||||
<View key={index}>
|
||||
<View style={s.exerciseRow}>
|
||||
<RNText style={[s.exerciseIndex, { color: accentColor }]}>
|
||||
{index + 1}
|
||||
</RNText>
|
||||
<RNText selectable style={[s.exerciseName, { color: colors.text.primary }]}>
|
||||
{exercise.name}
|
||||
</RNText>
|
||||
<RNText style={[s.exerciseDuration, { color: colors.text.tertiary }]}>
|
||||
{exercise.duration}s
|
||||
</RNText>
|
||||
</View>
|
||||
<RNText style={styles.exerciseName}>{exercise.name}</RNText>
|
||||
<RNText style={styles.exerciseDuration}>{exercise.duration}s</RNText>
|
||||
{index < workout.exercises.length - 1 && (
|
||||
<View style={[s.exerciseSep, { backgroundColor: colors.border.glassLight }]} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.repeatNote}>
|
||||
<Ionicons name="repeat" size={16} color={colors.text.tertiary} />
|
||||
<RNText style={styles.repeatText}>{t('screens:workout.repeatRounds', { count: repeatCount })}</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
{repeatCount > 1 && (
|
||||
<View style={s.repeatRow}>
|
||||
<Icon name="repeat" size={13} color={colors.text.hint} />
|
||||
<RNText style={[s.repeatText, { color: colors.text.hint }]}>
|
||||
{t('screens:workout.repeatRounds', { count: repeatCount })}
|
||||
</RNText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Music */}
|
||||
<View style={styles.section}>
|
||||
<RNText style={styles.sectionTitle}>{t('screens:workout.music')}</RNText>
|
||||
<View style={styles.musicCard}>
|
||||
<View style={styles.musicIcon}>
|
||||
<Ionicons name="musical-notes" size={24} color={BRAND.PRIMARY} />
|
||||
</View>
|
||||
<View style={styles.musicInfo}>
|
||||
<RNText style={styles.musicName}>{t('screens:workout.musicMix', { vibe: musicVibeLabel })}</RNText>
|
||||
<RNText style={styles.musicDescription}>{t('screens:workout.curatedForWorkout')}</RNText>
|
||||
</View>
|
||||
{/* Music */}
|
||||
<View style={s.musicRow}>
|
||||
<Icon name="music.note" size={14} tintColor={colors.text.hint} />
|
||||
<RNText style={[s.musicText, { color: colors.text.tertiary }]}>
|
||||
{t('screens:workout.musicMix', { vibe: musicVibeLabel })}
|
||||
</RNText>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed Start Button */}
|
||||
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.startButton,
|
||||
pressed && styles.startButtonPressed,
|
||||
{/* CTA */}
|
||||
<Animated.View
|
||||
style={[
|
||||
s.bottomBar,
|
||||
{
|
||||
backgroundColor: colors.bg.base,
|
||||
paddingBottom: insets.bottom + SPACING[3],
|
||||
opacity: ctaAnim,
|
||||
transform: [
|
||||
{
|
||||
translateY: ctaAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [30, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
onPress={handleStartWorkout}
|
||||
>
|
||||
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID={isLocked ? 'workout-unlock-button' : 'workout-start-button'}
|
||||
style={({ pressed }) => [
|
||||
s.ctaButton,
|
||||
{ backgroundColor: isLocked ? ctaLockedBg : ctaBg },
|
||||
isLocked && { borderWidth: 1, borderColor: colors.border.glass },
|
||||
pressed && { opacity: 0.85, transform: [{ scale: 0.98 }] },
|
||||
]}
|
||||
onPress={handleStartWorkout}
|
||||
>
|
||||
{isLocked && (
|
||||
<Icon name="lock.fill" size={16} color={ctaLockedText} style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<RNText
|
||||
testID="workout-cta-text"
|
||||
style={[s.ctaText, { color: isLocked ? ctaLockedText : ctaText }]}
|
||||
>
|
||||
{isLocked ? t('screens:workout.unlockWithPremium') : t('screens:workout.startWorkout')}
|
||||
</RNText>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ─── Styles ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function createStyles(colors: ThemeColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg.base,
|
||||
},
|
||||
centered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
},
|
||||
const s = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
centered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[2],
|
||||
},
|
||||
|
||||
// Header
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: SPACING[4],
|
||||
paddingBottom: SPACING[3],
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
marginRight: SPACING[3],
|
||||
},
|
||||
glassButtonContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
},
|
||||
// Media
|
||||
mediaContainer: {
|
||||
height: 200,
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
thumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
|
||||
// Quick Stats
|
||||
quickStats: {
|
||||
flexDirection: 'row',
|
||||
gap: SPACING[2],
|
||||
marginBottom: SPACING[5],
|
||||
},
|
||||
statBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: SPACING[3],
|
||||
paddingVertical: SPACING[2],
|
||||
borderRadius: RADIUS.FULL,
|
||||
gap: SPACING[1],
|
||||
},
|
||||
statBadgeText: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// Title
|
||||
title: {
|
||||
...TYPOGRAPHY.TITLE_1,
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
|
||||
// Section
|
||||
section: {
|
||||
paddingVertical: SPACING[3],
|
||||
},
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
// Trainer
|
||||
trainerName: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
|
||||
// Divider
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.border.glass,
|
||||
marginVertical: SPACING[2],
|
||||
},
|
||||
// Metadata
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: SPACING[2],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
metaText: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
},
|
||||
metaDot: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
},
|
||||
|
||||
// Equipment
|
||||
equipmentItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[3],
|
||||
marginBottom: SPACING[2],
|
||||
},
|
||||
equipmentText: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
// Equipment
|
||||
equipmentText: {
|
||||
...TYPOGRAPHY.FOOTNOTE,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Exercises
|
||||
exercisesList: {
|
||||
gap: SPACING[2],
|
||||
},
|
||||
exerciseRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
gap: SPACING[3],
|
||||
},
|
||||
exerciseNumber: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
exerciseNumberText: {
|
||||
...TYPOGRAPHY.CALLOUT,
|
||||
color: BRAND.PRIMARY,
|
||||
fontWeight: '700',
|
||||
},
|
||||
exerciseName: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
color: colors.text.primary,
|
||||
flex: 1,
|
||||
},
|
||||
exerciseDuration: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
},
|
||||
repeatNote: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginTop: SPACING[2],
|
||||
paddingHorizontal: SPACING[2],
|
||||
},
|
||||
repeatText: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
},
|
||||
// Separator
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Music
|
||||
musicCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING[4],
|
||||
backgroundColor: colors.bg.surface,
|
||||
borderRadius: RADIUS.LG,
|
||||
gap: SPACING[3],
|
||||
},
|
||||
musicIcon: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
musicInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
musicName: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
musicDescription: {
|
||||
...TYPOGRAPHY.CAPTION_1,
|
||||
color: colors.text.tertiary,
|
||||
marginTop: 2,
|
||||
},
|
||||
// Card
|
||||
card: {
|
||||
borderRadius: RADIUS.LG,
|
||||
borderCurve: 'continuous',
|
||||
overflow: 'hidden',
|
||||
marginBottom: SPACING[4],
|
||||
},
|
||||
|
||||
// Bottom Bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[4],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border.glass,
|
||||
},
|
||||
// Timing
|
||||
timingRow: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: SPACING[4],
|
||||
},
|
||||
timingItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
timingDivider: {
|
||||
width: StyleSheet.hairlineWidth,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
timingValue: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
fontVariant: ['tabular-nums'],
|
||||
},
|
||||
timingLabel: {
|
||||
...TYPOGRAPHY.CAPTION_2,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
// Start Button
|
||||
startButton: {
|
||||
height: 56,
|
||||
borderRadius: RADIUS.LG,
|
||||
backgroundColor: BRAND.PRIMARY,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
startButtonPressed: {
|
||||
backgroundColor: BRAND.PRIMARY_DARK,
|
||||
transform: [{ scale: 0.98 }],
|
||||
},
|
||||
startButtonText: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
color: '#FFFFFF',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Section
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.HEADLINE,
|
||||
marginBottom: SPACING[3],
|
||||
},
|
||||
|
||||
// Exercise
|
||||
exerciseRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: SPACING[3],
|
||||
paddingHorizontal: SPACING[4],
|
||||
},
|
||||
exerciseIndex: {
|
||||
...TYPOGRAPHY.FOOTNOTE,
|
||||
fontVariant: ['tabular-nums'],
|
||||
width: 24,
|
||||
},
|
||||
exerciseName: {
|
||||
...TYPOGRAPHY.BODY,
|
||||
flex: 1,
|
||||
},
|
||||
exerciseDuration: {
|
||||
...TYPOGRAPHY.SUBHEADLINE,
|
||||
fontVariant: ['tabular-nums'],
|
||||
marginLeft: SPACING[3],
|
||||
},
|
||||
exerciseSep: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginLeft: SPACING[4] + 24,
|
||||
marginRight: SPACING[4],
|
||||
},
|
||||
repeatRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginTop: SPACING[2],
|
||||
paddingLeft: 24,
|
||||
},
|
||||
repeatText: {
|
||||
...TYPOGRAPHY.FOOTNOTE,
|
||||
},
|
||||
|
||||
// Music
|
||||
musicRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: SPACING[2],
|
||||
marginTop: SPACING[5],
|
||||
},
|
||||
musicText: {
|
||||
...TYPOGRAPHY.FOOTNOTE,
|
||||
},
|
||||
|
||||
// Bottom bar
|
||||
bottomBar: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingHorizontal: LAYOUT.SCREEN_PADDING,
|
||||
paddingTop: SPACING[3],
|
||||
},
|
||||
ctaButton: {
|
||||
height: 54,
|
||||
borderRadius: RADIUS.MD,
|
||||
borderCurve: 'continuous',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
ctaText: {
|
||||
...TYPOGRAPHY.BUTTON_LARGE,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState, useMemo } from 'react'
|
||||
import { View, StyleSheet, ScrollView, Pressable, Text as RNText } from 'react-native'
|
||||
import { useRouter, useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { Icon } from '@/src/shared/components/Icon'
|
||||
import {
|
||||
Host,
|
||||
Picker,
|
||||
@@ -80,7 +80,7 @@ export default function CategoryDetailScreen() {
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={handleBack} style={styles.backButton}>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text.primary} />
|
||||
<Icon name="chevron.left" size={24} color={colors.text.primary} />
|
||||
</Pressable>
|
||||
<StyledText size={22} weight="bold" color={colors.text.primary}>{categoryLabel}</StyledText>
|
||||
<View style={styles.backButton} />
|
||||
@@ -122,24 +122,24 @@ export default function CategoryDetailScreen() {
|
||||
onPress={() => handleWorkoutPress(workout.id)}
|
||||
>
|
||||
<View style={[styles.workoutAvatar, { backgroundColor: BRAND.PRIMARY }]}>
|
||||
<Ionicons name="flame" size={20} color="#FFFFFF" />
|
||||
<Icon name="flame.fill" size={20} color="#FFFFFF" />
|
||||
</View>
|
||||
<View style={styles.workoutInfo}>
|
||||
<StyledText size={17} weight="semibold" color={colors.text.primary}>{workout.title}</StyledText>
|
||||
<StyledText size={13} color={colors.text.tertiary}>
|
||||
{t('durationLevel', { duration: workout.duration, level: t(`levels.${workout.level.toLowerCase()}`) })}
|
||||
{t('durationLevel', { duration: workout.duration, level: t(`levels.${(workout.level ?? 'Beginner').toLowerCase()}`) })}
|
||||
</StyledText>
|
||||
</View>
|
||||
<View style={styles.workoutMeta}>
|
||||
<StyledText size={13} color={BRAND.PRIMARY}>{t('units.calUnit', { count: workout.calories })}</StyledText>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.text.tertiary} />
|
||||
<Icon name="chevron.right" size={16} color={colors.text.tertiary} />
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
{translatedWorkouts.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<Ionicons name="barbell-outline" size={48} color={colors.text.tertiary} />
|
||||
<Icon name="dumbbell" size={48} color={colors.text.tertiary} />
|
||||
<StyledText size={17} color={colors.text.tertiary} style={{ marginTop: SPACING[3] }}>
|
||||
No workouts found
|
||||
</StyledText>
|
||||
|
||||
643
docs/maestro-e2e-testing-strategy.md
Normal file
643
docs/maestro-e2e-testing-strategy.md
Normal file
@@ -0,0 +1,643 @@
|
||||
# Maestro E2E Testing Strategy for TabataFit
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Maestro** is a mobile UI testing framework that uses YAML-based test flows. It's ideal for TabataFit because:
|
||||
- ✅ Declarative YAML syntax (no code required)
|
||||
- ✅ Built-in support for React Native
|
||||
- ✅ Handles animations and async operations gracefully
|
||||
- ✅ Excellent for regression testing critical user flows
|
||||
- ✅ Can run on physical devices and simulators
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before implementing these tests, ensure the following features are complete:
|
||||
|
||||
### Required Features (Implement First)
|
||||
- [ ] Onboarding flow (5 screens + paywall)
|
||||
- [ ] Workout player with timer controls
|
||||
- [ ] Browse/Workouts tab with workout cards
|
||||
- [ ] Category filtering (Full Body, Core, Cardio, etc.)
|
||||
- [ ] Collections feature
|
||||
- [ ] Trainer profiles
|
||||
- [ ] Subscription/paywall integration
|
||||
- [ ] Workout completion screen
|
||||
- [ ] Profile/settings screen
|
||||
|
||||
### Nice to Have (Can Add Later)
|
||||
- [ ] Activity history tracking
|
||||
- [ ] Offline mode support
|
||||
- [ ] Deep linking
|
||||
- [ ] Push notifications
|
||||
|
||||
---
|
||||
|
||||
## Priority Test Flows
|
||||
|
||||
### **P0 - Critical Flows (Must Test Every Release)**
|
||||
1. **Onboarding → First Workout**
|
||||
2. **Browse → Select Workout → Play → Complete**
|
||||
3. **Subscription Purchase Flow**
|
||||
4. **Background/Foreground During Workout**
|
||||
|
||||
### **P1 - High Priority**
|
||||
5. **Category Filtering**
|
||||
6. **Collection Navigation**
|
||||
7. **Trainer Workout Discovery**
|
||||
8. **Profile Settings & Data Persistence**
|
||||
|
||||
### **P2 - Medium Priority**
|
||||
9. **Activity History Tracking**
|
||||
10. **Offline Mode Behavior**
|
||||
11. **Deep Linking**
|
||||
12. **Push Notifications**
|
||||
|
||||
---
|
||||
|
||||
## Test Suite Structure
|
||||
|
||||
```
|
||||
.maestro/
|
||||
├── config.yaml # Global test configuration
|
||||
├── flows/
|
||||
│ ├── critical/ # P0 flows - Run on every PR
|
||||
│ │ ├── onboarding.yaml
|
||||
│ │ ├── workoutComplete.yaml
|
||||
│ │ └── subscription.yaml
|
||||
│ ├── core/ # P1 flows - Run before release
|
||||
│ │ ├── browseAndPlay.yaml
|
||||
│ │ ├── categoryFilter.yaml
|
||||
│ │ ├── collections.yaml
|
||||
│ │ └── trainers.yaml
|
||||
│ └── regression/ # P2 flows - Run nightly
|
||||
│ ├── activityHistory.yaml
|
||||
│ ├── offlineMode.yaml
|
||||
│ └── settings.yaml
|
||||
├── helpers/
|
||||
│ ├── common.yaml # Shared test steps
|
||||
│ ├── assertions.yaml # Custom assertions
|
||||
│ └── mock-data.yaml # Test data
|
||||
└── environments/
|
||||
├── staging.yaml
|
||||
└── production.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### 1. Install Maestro CLI
|
||||
|
||||
```bash
|
||||
# macOS/Linux
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
# Verify installation
|
||||
maestro --version
|
||||
```
|
||||
|
||||
### 2. Setup Test Directory Structure
|
||||
|
||||
```bash
|
||||
mkdir -p .maestro/flows/{critical,core,regression}
|
||||
mkdir -p .maestro/helpers
|
||||
mkdir -p .maestro/environments
|
||||
```
|
||||
|
||||
### 3. Maestro Configuration (`config.yaml`)
|
||||
|
||||
```yaml
|
||||
# .maestro/config.yaml
|
||||
appId: com.tabatafit.app
|
||||
name: TabataFit E2E Tests
|
||||
|
||||
# Timeouts
|
||||
timeout: 30000 # 30 seconds default
|
||||
retries: 2
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
TEST_USER_NAME: "Test User"
|
||||
TEST_USER_EMAIL: "test@example.com"
|
||||
|
||||
# Include flows
|
||||
include:
|
||||
- flows/critical/*.yaml
|
||||
- flows/core/*.yaml
|
||||
|
||||
# Exclude on CI
|
||||
exclude:
|
||||
- flows/regression/offlineMode.yaml # Requires airplane mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P0 Critical Test Flows
|
||||
|
||||
### Test 1: Complete Onboarding Flow
|
||||
|
||||
**File:** `.maestro/flows/critical/onboarding.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Complete Onboarding & First Workout
|
||||
|
||||
onFlowStart:
|
||||
- clearState
|
||||
|
||||
steps:
|
||||
# Splash/Loading
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
|
||||
# Screen 1: Problem - "Not Enough Time"
|
||||
- assertVisible: "Not enough time"
|
||||
- tapOn: "Continue"
|
||||
|
||||
# Screen 2: Empathy
|
||||
- assertVisible: "We get it"
|
||||
- tapOn: "Continue"
|
||||
|
||||
# Screen 3: Solution
|
||||
- assertVisible: "4-minute workouts"
|
||||
- tapOn: "Continue"
|
||||
|
||||
# Screen 4: Wow Moment
|
||||
- assertVisible: "Transform your body"
|
||||
- tapOn: "Get Started"
|
||||
|
||||
# Screen 5: Personalization
|
||||
- tapOn: "Name input"
|
||||
- inputText: "Test User"
|
||||
- tapOn: "Beginner"
|
||||
- tapOn: "Lose Weight"
|
||||
- tapOn: "3 times per week"
|
||||
- tapOn: "Start My Journey"
|
||||
|
||||
# Screen 6: Paywall (or skip in test env)
|
||||
- runScript: |
|
||||
if (maestro.env.SKIP_PAYWALL === 'true') {
|
||||
maestro.tapOn('Maybe Later');
|
||||
}
|
||||
|
||||
# Should land on Home
|
||||
- assertVisible: "Good morning|Good afternoon|Good evening"
|
||||
- assertVisible: "Test User"
|
||||
|
||||
onFlowComplete:
|
||||
- takeScreenshot: "onboarding-complete"
|
||||
```
|
||||
|
||||
### Test 2: Browse, Select, and Complete Workout
|
||||
|
||||
**File:** `.maestro/flows/critical/workoutComplete.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Browse, Play & Complete Workout
|
||||
|
||||
steps:
|
||||
# Navigate to Workouts tab
|
||||
- tapOn: "Workouts"
|
||||
- waitForAnimationToEnd
|
||||
|
||||
# Wait for data to load
|
||||
- assertVisible: "All|Full Body|Core|Upper Body"
|
||||
|
||||
# Select first workout
|
||||
- tapOn:
|
||||
id: "workout-card-0"
|
||||
optional: false
|
||||
|
||||
# Workout Detail Screen
|
||||
- assertVisible: "Start Workout"
|
||||
- tapOn: "Start Workout"
|
||||
|
||||
# Player Screen
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
|
||||
# Verify timer is running
|
||||
- assertVisible: "Get Ready|WORK|REST"
|
||||
|
||||
# Fast-forward through workout (simulation)
|
||||
- repeat:
|
||||
times: 3
|
||||
commands:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- assertVisible: "WORK|REST"
|
||||
|
||||
# Complete workout
|
||||
- tapOn:
|
||||
id: "done-button"
|
||||
optional: true
|
||||
|
||||
# Complete Screen
|
||||
- assertVisible: "Workout Complete|Great Job"
|
||||
- assertVisible: "Calories"
|
||||
- assertVisible: "Duration"
|
||||
|
||||
# Return to home
|
||||
- tapOn: "Done|Continue"
|
||||
- assertVisible: "Home|Workouts"
|
||||
|
||||
onFlowComplete:
|
||||
- takeScreenshot: "workout-completed"
|
||||
```
|
||||
|
||||
### Test 3: Subscription Purchase Flow
|
||||
|
||||
**File:** `.maestro/flows/critical/subscription.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Subscription Purchase Flow
|
||||
|
||||
steps:
|
||||
# Trigger paywall (via profile or workout limit)
|
||||
- tapOn: "Profile"
|
||||
- tapOn: "Upgrade to Premium"
|
||||
|
||||
# Paywall Screen
|
||||
- assertVisible: "Unlock Everything|Premium"
|
||||
- assertVisible: "yearly|monthly"
|
||||
|
||||
# Select plan
|
||||
- tapOn:
|
||||
id: "yearly-plan"
|
||||
|
||||
# Verify Apple Pay/Google Pay sheet appears
|
||||
- assertVisible: "Subscribe|Confirm"
|
||||
|
||||
# Note: Actual purchase is mocked in test env
|
||||
- runScript: |
|
||||
if (maestro.env.USE_MOCK_PURCHASE === 'true') {
|
||||
maestro.tapOn('Mock Purchase Success');
|
||||
}
|
||||
|
||||
# Verify premium activated
|
||||
- assertVisible: "Premium Active|You're all set"
|
||||
|
||||
tags:
|
||||
- purchase
|
||||
- revenue-critical
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## P1 Core Test Flows
|
||||
|
||||
### Test 4: Category Filtering
|
||||
|
||||
**File:** `.maestro/flows/core/categoryFilter.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Category Filtering
|
||||
|
||||
steps:
|
||||
- tapOn: "Workouts"
|
||||
- waitForAnimationToEnd
|
||||
|
||||
# Test each category
|
||||
- tapOn: "Full Body"
|
||||
- assertVisible: "Full Body"
|
||||
|
||||
- tapOn: "Core"
|
||||
- assertVisible: "Core"
|
||||
|
||||
- tapOn: "Cardio"
|
||||
- assertVisible: "Cardio"
|
||||
|
||||
- tapOn: "All"
|
||||
- assertVisible: "All Workouts"
|
||||
|
||||
# Verify filter changes content
|
||||
- runScript: |
|
||||
const before = maestro.getElementText('workout-count');
|
||||
maestro.tapOn('Core');
|
||||
const after = maestro.getElementText('workout-count');
|
||||
assert(before !== after, 'Filter should change workout count');
|
||||
```
|
||||
|
||||
### Test 5: Collection Navigation
|
||||
|
||||
**File:** `.maestro/flows/core/collections.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Collection Navigation
|
||||
|
||||
steps:
|
||||
- tapOn: "Browse"
|
||||
- waitForAnimationToEnd
|
||||
|
||||
# Scroll to collections
|
||||
- swipe:
|
||||
direction: UP
|
||||
duration: 1000
|
||||
|
||||
# Tap first collection
|
||||
- tapOn:
|
||||
id: "collection-card-0"
|
||||
|
||||
# Collection Detail Screen
|
||||
- assertVisible: "Collection"
|
||||
- assertVisible: "workouts"
|
||||
|
||||
# Start collection workout
|
||||
- tapOn: "Start"
|
||||
- assertVisible: "Player|Timer"
|
||||
|
||||
onFlowComplete:
|
||||
- takeScreenshot: "collection-navigation"
|
||||
```
|
||||
|
||||
### Test 6: Trainer Discovery
|
||||
|
||||
**File:** `.maestro/flows/core/trainers.yaml`
|
||||
|
||||
```yaml
|
||||
appId: com.tabatafit.app
|
||||
name: Trainer Discovery
|
||||
|
||||
steps:
|
||||
- tapOn: "Browse"
|
||||
|
||||
# Navigate to trainers section
|
||||
- swipe:
|
||||
direction: UP
|
||||
|
||||
# Select trainer
|
||||
- tapOn:
|
||||
id: "trainer-card-0"
|
||||
|
||||
# Verify trainer profile
|
||||
- assertVisible: "workouts"
|
||||
|
||||
# Select trainer's workout
|
||||
- tapOn:
|
||||
id: "workout-card-0"
|
||||
|
||||
# Should show workout detail
|
||||
- assertVisible: "Start Workout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reusable Test Helpers
|
||||
|
||||
### Common Actions (`helpers/common.yaml`)
|
||||
|
||||
```yaml
|
||||
# .maestro/helpers/common.yaml
|
||||
appId: com.tabatafit.app
|
||||
|
||||
# Launch app fresh
|
||||
- launchApp:
|
||||
clearState: true
|
||||
|
||||
# Wait for data loading
|
||||
- waitForDataLoad:
|
||||
commands:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertVisible: ".*" # Any content loaded
|
||||
|
||||
# Handle permission dialogs
|
||||
- handlePermissions:
|
||||
commands:
|
||||
- tapOn:
|
||||
text: "Allow"
|
||||
optional: true
|
||||
- tapOn:
|
||||
text: "OK"
|
||||
optional: true
|
||||
|
||||
# Navigate to tab
|
||||
- navigateToTab:
|
||||
params:
|
||||
tabName: ${tab}
|
||||
commands:
|
||||
- tapOn: ${tab}
|
||||
|
||||
# Start workout from detail
|
||||
- startWorkout:
|
||||
commands:
|
||||
- tapOn: "Start Workout"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- assertVisible: "Get Ready|WORK"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Install Maestro
|
||||
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
# Run single test
|
||||
maestro test .maestro/flows/critical/onboarding.yaml
|
||||
|
||||
# Run all critical tests
|
||||
maestro test .maestro/flows/critical/
|
||||
|
||||
# Run with specific environment
|
||||
maestro test --env SKIP_PAYWALL=true .maestro/flows/
|
||||
|
||||
# Record video of test
|
||||
maestro record .maestro/flows/critical/workoutComplete.yaml
|
||||
|
||||
# Run with tags
|
||||
maestro test --include-tags=critical .maestro/flows/
|
||||
```
|
||||
|
||||
### CI/CD Integration (GitHub Actions)
|
||||
|
||||
```yaml
|
||||
# .github/workflows/maestro.yml
|
||||
name: Maestro E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Install Maestro
|
||||
run: curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||
|
||||
- name: Start iOS Simulator
|
||||
run: |
|
||||
xcrun simctl boot "iPhone 15"
|
||||
sleep 10
|
||||
|
||||
- name: Install App
|
||||
run: |
|
||||
npm install
|
||||
npx expo prebuild
|
||||
npx pod install
|
||||
npx react-native run-ios --simulator="iPhone 15"
|
||||
|
||||
- name: Run Critical Tests
|
||||
run: |
|
||||
export MAESTRO_DRIVER_STARTUP_TIMEOUT=120000
|
||||
maestro test .maestro/flows/critical/
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: maestro-results
|
||||
path: |
|
||||
~/.maestro/tests/
|
||||
*.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Matrix
|
||||
|
||||
| Feature | Test File | Priority | Frequency | Status |
|
||||
|---------|-----------|----------|-----------|--------|
|
||||
| Onboarding | `onboarding.yaml` | P0 | Every PR | ⏳ Pending |
|
||||
| Workout Play | `workoutComplete.yaml` | P0 | Every PR | ⏳ Pending |
|
||||
| Purchase | `subscription.yaml` | P0 | Every PR | ⏳ Pending |
|
||||
| Category Filter | `categoryFilter.yaml` | P1 | Pre-release | ⏳ Pending |
|
||||
| Collections | `collections.yaml` | P1 | Pre-release | ⏳ Pending |
|
||||
| Trainers | `trainers.yaml` | P1 | Pre-release | ⏳ Pending |
|
||||
| Activity | `activityHistory.yaml` | P2 | Nightly | ⏳ Pending |
|
||||
| Offline | `offlineMode.yaml` | P2 | Weekly | ⏳ Pending |
|
||||
|
||||
---
|
||||
|
||||
## React Native Prerequisites
|
||||
|
||||
Before running tests, add `testID` props to components for reliable selectors:
|
||||
|
||||
```tsx
|
||||
// WorkoutCard.tsx
|
||||
<Pressable testID={`workout-card-${index}`}>
|
||||
{/* ... */}
|
||||
</Pressable>
|
||||
|
||||
// WorkoutPlayer.tsx
|
||||
<Button testID="done-button" title="Done" />
|
||||
|
||||
// Paywall.tsx
|
||||
<Pressable testID="yearly-plan">
|
||||
{/* ... */}
|
||||
</Pressable>
|
||||
```
|
||||
|
||||
### Required testIDs Checklist
|
||||
|
||||
- [ ] `workout-card-{index}` - Workout list items
|
||||
- [ ] `collection-card-{index}` - Collection items
|
||||
- [ ] `trainer-card-{index}` - Trainer items
|
||||
- [ ] `done-button` - Complete workout button
|
||||
- [ ] `yearly-plan` / `monthly-plan` - Subscription plans
|
||||
- [ ] `start-workout-button` - Start workout CTA
|
||||
- [ ] `category-{name}` - Category filter buttons
|
||||
- [ ] `tab-{name}` - Bottom navigation tabs
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env.maestro` file:
|
||||
|
||||
```bash
|
||||
# Test Configuration
|
||||
SKIP_PAYWALL=true
|
||||
USE_MOCK_PURCHASE=true
|
||||
TEST_USER_NAME=Test User
|
||||
TEST_USER_EMAIL=test@example.com
|
||||
|
||||
# API Configuration (if needed)
|
||||
API_BASE_URL=https://api-staging.tabatafit.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Tests fail on first run**
|
||||
- Clear app state: `maestro test --clear-state`
|
||||
- Increase timeout in config.yaml
|
||||
|
||||
2. **Element not found**
|
||||
- Verify testID is set correctly
|
||||
- Add wait times before assertions
|
||||
- Check for animations completing
|
||||
|
||||
3. **Purchase tests fail**
|
||||
- Ensure `USE_MOCK_PURCHASE=true` in test env
|
||||
- Use sandbox/test products
|
||||
|
||||
4. **Slow tests**
|
||||
- Use `waitForAnimationToEnd` with shorter timeouts
|
||||
- Disable animations in test builds
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Interactive mode
|
||||
maestro studio
|
||||
|
||||
# View hierarchy
|
||||
maestro hierarchy
|
||||
|
||||
# Record test execution
|
||||
maestro record <test-file>
|
||||
|
||||
# Verbose logging
|
||||
maestro test --verbose <test-file>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (After Features Are Complete)
|
||||
|
||||
1. ✅ Create `.maestro/` directory structure
|
||||
2. ✅ Write `config.yaml`
|
||||
3. ✅ Implement P0 critical test flows
|
||||
4. ✅ Add testIDs to React Native components
|
||||
5. ✅ Run tests locally
|
||||
6. ✅ Setup CI/CD pipeline
|
||||
7. ⏳ Implement P1 core test flows
|
||||
8. ⏳ Add visual regression tests
|
||||
9. ⏳ Setup nightly regression suite
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Maestro Documentation](https://maestro.mobile.dev/)
|
||||
- [Maestro YAML Reference](https://maestro.mobile.dev/api-reference/commands)
|
||||
- [React Native Testing with Maestro](https://maestro.mobile.dev/platform-support/react-native)
|
||||
- [Maestro Best Practices](https://maestro.mobile.dev/advanced/best-practices)
|
||||
|
||||
---
|
||||
|
||||
**Created:** March 17, 2026
|
||||
**Status:** Implementation Pending (Waiting for feature completion)
|
||||
36
eas.json
Normal file
36
eas.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.1",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {
|
||||
"appleId": "millianlmx@icloud.com",
|
||||
"ascAppId": "REPLACE_WITH_APP_STORE_CONNECT_APP_ID",
|
||||
"appleTeamId": "REPLACE_WITH_APPLE_TEAM_ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1990
package-lock.json
generated
1990
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -8,12 +8,25 @@
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
"lint": "expo lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:render": "vitest run --config vitest.config.render.ts",
|
||||
"test:maestro": "maestro test .maestro/flows",
|
||||
"test:maestro:onboarding": "maestro test .maestro/flows/onboarding.yaml",
|
||||
"test:maestro:programs": "maestro test .maestro/flows/program-browse.yaml",
|
||||
"test:maestro:tabs": "maestro test .maestro/flows/tab-navigation.yaml",
|
||||
"test:maestro:paywall": "maestro test .maestro/flows/subscription.yaml",
|
||||
"test:maestro:player": "maestro test .maestro/flows/workout-player.yaml",
|
||||
"test:maestro:activity": "maestro test .maestro/flows/activity-tab.yaml",
|
||||
"test:maestro:profile": "maestro test .maestro/flows/profile-settings.yaml",
|
||||
"test:maestro:all": "maestro test .maestro/flows/all-tests.yaml",
|
||||
"test:maestro:reset": "maestro test .maestro/flows/reset-state.yaml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/inter": "^0.4.2",
|
||||
"@expo/ui": "~0.2.0-beta.9",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||
"@react-navigation/elements": "^2.6.3",
|
||||
@@ -46,7 +59,6 @@
|
||||
"expo-video": "~3.0.16",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"i18next": "^25.8.12",
|
||||
"lucide-react": "^0.576.0",
|
||||
"posthog-react-native": "^4.36.0",
|
||||
"posthog-react-native-session-replay": "^1.5.0",
|
||||
"react": "19.1.0",
|
||||
@@ -64,10 +76,16 @@
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-native": "^5.4.3",
|
||||
"@testing-library/react-native": "^13.3.3",
|
||||
"@types/react": "~19.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
"jsdom": "^29.0.1",
|
||||
"react-test-renderer": "^19.1.0",
|
||||
"typescript": "~5.9.2",
|
||||
"vitest": "^4.1.1"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
56
scripts/deploy-functions.sh
Executable file
56
scripts/deploy-functions.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
DEPLOY_HOST="${DEPLOY_HOST:-1000co.fr}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-millian}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/supabase/volumes/functions}"
|
||||
WORKER_PATH="${WORKER_PATH:-/opt/supabase/youtube-worker}"
|
||||
# ───────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
FUNCTIONS_DIR="$SCRIPT_DIR/../supabase/functions"
|
||||
WORKER_DIR="$SCRIPT_DIR/../youtube-worker"
|
||||
|
||||
# ── Deploy edge functions ──────────────────────────────────────
|
||||
echo "==> Deploying edge functions"
|
||||
rsync -avz --delete \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.DS_Store' \
|
||||
"$FUNCTIONS_DIR/" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
|
||||
|
||||
echo "Restarting supabase-edge-functions..."
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" "docker restart supabase-edge-functions"
|
||||
|
||||
# ── Deploy youtube-worker sidecar ──────────────────────────────
|
||||
echo ""
|
||||
echo "==> Deploying youtube-worker sidecar"
|
||||
rsync -avz --delete \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.DS_Store' \
|
||||
"$WORKER_DIR/" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$WORKER_PATH/"
|
||||
|
||||
echo "Building and restarting youtube-worker..."
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" "\
|
||||
cd $WORKER_PATH && \
|
||||
docker build -t youtube-worker:latest . && \
|
||||
docker stop youtube-worker 2>/dev/null || true && \
|
||||
docker rm youtube-worker 2>/dev/null || true && \
|
||||
docker run -d \
|
||||
--name youtube-worker \
|
||||
--restart unless-stopped \
|
||||
--network supabase_supabase-network \
|
||||
-e SUPABASE_URL=\$(docker exec supabase-edge-functions printenv SUPABASE_URL) \
|
||||
-e SUPABASE_SERVICE_ROLE_KEY=\$(docker exec supabase-edge-functions printenv SUPABASE_SERVICE_ROLE_KEY) \
|
||||
-e SUPABASE_PUBLIC_URL=https://supabase.1000co.fr \
|
||||
-e GEMINI_API_KEY=\$(cat /opt/supabase/.env.gemini 2>/dev/null || echo '') \
|
||||
-e STORAGE_BUCKET=workout-audio \
|
||||
-e PORT=3001 \
|
||||
youtube-worker:latest"
|
||||
|
||||
echo ""
|
||||
echo "Done. Verifying youtube-worker health..."
|
||||
sleep 3
|
||||
ssh "$DEPLOY_USER@$DEPLOY_HOST" "docker logs youtube-worker --tail 5"
|
||||
40
skills-lock.json
Normal file
40
skills-lock.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/__tests__/components/StyledText.test.tsx
Normal file
142
src/__tests__/components/StyledText.test.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
type FontWeight = 'regular' | 'medium' | 'semibold' | 'bold'
|
||||
|
||||
const WEIGHT_MAP: Record<FontWeight, string> = {
|
||||
regular: '400',
|
||||
medium: '500',
|
||||
semibold: '600',
|
||||
bold: '700',
|
||||
}
|
||||
|
||||
describe('StyledText', () => {
|
||||
describe('weight mapping', () => {
|
||||
it('should map regular to 400', () => {
|
||||
expect(WEIGHT_MAP['regular']).toBe('400')
|
||||
})
|
||||
|
||||
it('should map medium to 500', () => {
|
||||
expect(WEIGHT_MAP['medium']).toBe('500')
|
||||
})
|
||||
|
||||
it('should map semibold to 600', () => {
|
||||
expect(WEIGHT_MAP['semibold']).toBe('600')
|
||||
})
|
||||
|
||||
it('should map bold to 700', () => {
|
||||
expect(WEIGHT_MAP['bold']).toBe('700')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default values', () => {
|
||||
it('should have default size of 17', () => {
|
||||
const defaultSize = 17
|
||||
expect(defaultSize).toBe(17)
|
||||
})
|
||||
|
||||
it('should have default weight of regular', () => {
|
||||
const defaultWeight: FontWeight = 'regular'
|
||||
expect(WEIGHT_MAP[defaultWeight]).toBe('400')
|
||||
})
|
||||
})
|
||||
|
||||
describe('style computation', () => {
|
||||
const computeTextStyle = (size: number, weight: FontWeight, color: string) => ({
|
||||
fontSize: size,
|
||||
fontWeight: WEIGHT_MAP[weight],
|
||||
color,
|
||||
})
|
||||
|
||||
it('should compute correct style with defaults', () => {
|
||||
const style = computeTextStyle(17, 'regular', '#FFFFFF')
|
||||
expect(style.fontSize).toBe(17)
|
||||
expect(style.fontWeight).toBe('400')
|
||||
expect(style.color).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
it('should compute correct style with custom size', () => {
|
||||
const style = computeTextStyle(24, 'regular', '#FFFFFF')
|
||||
expect(style.fontSize).toBe(24)
|
||||
})
|
||||
|
||||
it('should compute correct style with bold weight', () => {
|
||||
const style = computeTextStyle(17, 'bold', '#FFFFFF')
|
||||
expect(style.fontWeight).toBe('700')
|
||||
})
|
||||
|
||||
it('should compute correct style with custom color', () => {
|
||||
const style = computeTextStyle(17, 'regular', '#FF0000')
|
||||
expect(style.color).toBe('#FF0000')
|
||||
})
|
||||
|
||||
it('should compute correct style with all custom props', () => {
|
||||
const style = computeTextStyle(20, 'semibold', '#5AC8FA')
|
||||
expect(style.fontSize).toBe(20)
|
||||
expect(style.fontWeight).toBe('600')
|
||||
expect(style.color).toBe('#5AC8FA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('numberOfLines handling', () => {
|
||||
it('should accept numberOfLines prop', () => {
|
||||
const numberOfLines = 2
|
||||
expect(numberOfLines).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle undefined numberOfLines', () => {
|
||||
const numberOfLines: number | undefined = undefined
|
||||
expect(numberOfLines).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('style merging', () => {
|
||||
const mergeStyles = (baseStyle: object, customStyle: object | undefined) => {
|
||||
return customStyle ? [baseStyle, customStyle] : [baseStyle]
|
||||
}
|
||||
|
||||
it('should merge custom style with base style', () => {
|
||||
const base = { fontSize: 17, fontWeight: '400' }
|
||||
const custom = { marginTop: 10 }
|
||||
const merged = mergeStyles(base, custom)
|
||||
|
||||
expect(merged).toHaveLength(2)
|
||||
expect(merged[0]).toEqual(base)
|
||||
expect(merged[1]).toEqual(custom)
|
||||
})
|
||||
|
||||
it('should return only base style when no custom style', () => {
|
||||
const base = { fontSize: 17, fontWeight: '400' }
|
||||
const merged = mergeStyles(base, undefined)
|
||||
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0]).toEqual(base)
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme color integration', () => {
|
||||
const mockThemeColors = {
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#8E8E93',
|
||||
tertiary: '#636366',
|
||||
},
|
||||
}
|
||||
|
||||
it('should use primary text color as default', () => {
|
||||
const defaultColor = mockThemeColors.text.primary
|
||||
expect(defaultColor).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
it('should allow color override', () => {
|
||||
const customColor = '#FF0000'
|
||||
const resolvedColor = customColor || mockThemeColors.text.primary
|
||||
expect(resolvedColor).toBe('#FF0000')
|
||||
})
|
||||
|
||||
it('should fallback to theme color when no override', () => {
|
||||
const customColor: string | undefined = undefined
|
||||
const resolvedColor = customColor || mockThemeColors.text.primary
|
||||
expect(resolvedColor).toBe('#FFFFFF')
|
||||
})
|
||||
})
|
||||
})
|
||||
113
src/__tests__/components/VideoPlayer.test.tsx
Normal file
113
src/__tests__/components/VideoPlayer.test.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { BRAND } from '../../shared/constants/colors'
|
||||
|
||||
type VideoPlayerMode = 'preview' | 'background'
|
||||
|
||||
interface VideoPlayerConfig {
|
||||
loop: boolean
|
||||
muted: boolean
|
||||
volume: number
|
||||
}
|
||||
|
||||
function getVideoPlayerConfig(mode: VideoPlayerMode): VideoPlayerConfig {
|
||||
return {
|
||||
loop: true,
|
||||
muted: mode === 'preview',
|
||||
volume: mode === 'background' ? 0.3 : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowGradient(videoUrl: string | undefined): boolean {
|
||||
return !videoUrl
|
||||
}
|
||||
|
||||
function shouldPlayVideo(isPlaying: boolean, videoUrl: string | undefined): boolean {
|
||||
return isPlaying && !!videoUrl
|
||||
}
|
||||
|
||||
describe('VideoPlayer', () => {
|
||||
describe('video player configuration', () => {
|
||||
it('should configure preview mode with muted audio', () => {
|
||||
const config = getVideoPlayerConfig('preview')
|
||||
expect(config.loop).toBe(true)
|
||||
expect(config.muted).toBe(true)
|
||||
expect(config.volume).toBe(0)
|
||||
})
|
||||
|
||||
it('should configure background mode with low volume', () => {
|
||||
const config = getVideoPlayerConfig('background')
|
||||
expect(config.loop).toBe(true)
|
||||
expect(config.muted).toBe(false)
|
||||
expect(config.volume).toBe(0.3)
|
||||
})
|
||||
|
||||
it('should always loop regardless of mode', () => {
|
||||
expect(getVideoPlayerConfig('preview').loop).toBe(true)
|
||||
expect(getVideoPlayerConfig('background').loop).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gradient fallback', () => {
|
||||
it('should show gradient when no video URL', () => {
|
||||
expect(shouldShowGradient(undefined)).toBe(true)
|
||||
expect(shouldShowGradient('')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not show gradient when video URL exists', () => {
|
||||
expect(shouldShowGradient('https://example.com/video.m3u8')).toBe(false)
|
||||
expect(shouldShowGradient('https://example.com/video.mp4')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('playback control', () => {
|
||||
it('should play when isPlaying is true and video exists', () => {
|
||||
expect(shouldPlayVideo(true, 'https://example.com/video.mp4')).toBe(true)
|
||||
})
|
||||
|
||||
it('should not play when isPlaying is false', () => {
|
||||
expect(shouldPlayVideo(false, 'https://example.com/video.mp4')).toBe(false)
|
||||
})
|
||||
|
||||
it('should not play when no video URL', () => {
|
||||
expect(shouldPlayVideo(true, undefined)).toBe(false)
|
||||
expect(shouldPlayVideo(true, '')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('default gradient colors', () => {
|
||||
it('should use brand colors as default gradient', () => {
|
||||
const defaultColors = [BRAND.PRIMARY, BRAND.PRIMARY_DARK]
|
||||
expect(defaultColors[0]).toBe(BRAND.PRIMARY)
|
||||
expect(defaultColors[1]).toBe(BRAND.PRIMARY_DARK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('video URL validation', () => {
|
||||
it('should accept HLS streams', () => {
|
||||
const hlsUrl = 'https://example.com/video.m3u8'
|
||||
expect(shouldShowGradient(hlsUrl)).toBe(false)
|
||||
})
|
||||
|
||||
it('should accept MP4 files', () => {
|
||||
const mp4Url = 'https://example.com/video.mp4'
|
||||
expect(shouldShowGradient(mp4Url)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle null/undefined', () => {
|
||||
expect(shouldShowGradient(null as any)).toBe(true)
|
||||
expect(shouldShowGradient(undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mode-specific behavior', () => {
|
||||
it('preview mode should be silent', () => {
|
||||
const previewConfig = getVideoPlayerConfig('preview')
|
||||
expect(previewConfig.muted || previewConfig.volume === 0).toBe(true)
|
||||
})
|
||||
|
||||
it('background mode should have audible audio', () => {
|
||||
const bgConfig = getVideoPlayerConfig('background')
|
||||
expect(bgConfig.volume).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
148
src/__tests__/components/WorkoutCard.test.tsx
Normal file
148
src/__tests__/components/WorkoutCard.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { View, Text } from 'react-native'
|
||||
import { render } from '@testing-library/react-native'
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
'full-body': '#FF6B35',
|
||||
'core': '#5AC8FA',
|
||||
'upper-body': '#BF5AF2',
|
||||
'lower-body': '#30D158',
|
||||
'cardio': '#FF9500',
|
||||
}
|
||||
|
||||
describe('WorkoutCard logic', () => {
|
||||
describe('category colors', () => {
|
||||
it('should map full-body to primary brand color', () => {
|
||||
expect(CATEGORY_COLORS['full-body']).toBe('#FF6B35')
|
||||
})
|
||||
|
||||
it('should map core to ice blue', () => {
|
||||
expect(CATEGORY_COLORS['core']).toBe('#5AC8FA')
|
||||
})
|
||||
|
||||
it('should map upper-body to purple', () => {
|
||||
expect(CATEGORY_COLORS['upper-body']).toBe('#BF5AF2')
|
||||
})
|
||||
|
||||
it('should map lower-body to green', () => {
|
||||
expect(CATEGORY_COLORS['lower-body']).toBe('#30D158')
|
||||
})
|
||||
|
||||
it('should map cardio to orange', () => {
|
||||
expect(CATEGORY_COLORS['cardio']).toBe('#FF9500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('display formatting', () => {
|
||||
const formatDuration = (minutes: number): string => `${minutes} MIN`
|
||||
const formatCalories = (calories: number): string => `${calories} CAL`
|
||||
const formatLevel = (level: string): string => level.toUpperCase()
|
||||
|
||||
it('should format duration correctly', () => {
|
||||
expect(formatDuration(4)).toBe('4 MIN')
|
||||
expect(formatDuration(8)).toBe('8 MIN')
|
||||
expect(formatDuration(12)).toBe('12 MIN')
|
||||
expect(formatDuration(20)).toBe('20 MIN')
|
||||
})
|
||||
|
||||
it('should format calories correctly', () => {
|
||||
expect(formatCalories(45)).toBe('45 CAL')
|
||||
expect(formatCalories(100)).toBe('100 CAL')
|
||||
})
|
||||
|
||||
it('should format level correctly', () => {
|
||||
expect(formatLevel('Beginner')).toBe('BEGINNER')
|
||||
expect(formatLevel('Intermediate')).toBe('INTERMEDIATE')
|
||||
expect(formatLevel('Advanced')).toBe('ADVANCED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('card variants', () => {
|
||||
type CardVariant = 'horizontal' | 'grid' | 'featured'
|
||||
|
||||
const getCardDimensions = (variant: CardVariant) => {
|
||||
switch (variant) {
|
||||
case 'horizontal':
|
||||
return { width: 200, height: 280 }
|
||||
case 'grid':
|
||||
return { flex: 1, aspectRatio: 0.75 }
|
||||
case 'featured':
|
||||
return { width: 320, height: 400 }
|
||||
default:
|
||||
return { width: 200, height: 280 }
|
||||
}
|
||||
}
|
||||
|
||||
it('should return correct dimensions for horizontal variant', () => {
|
||||
const dims = getCardDimensions('horizontal')
|
||||
expect(dims.width).toBe(200)
|
||||
expect(dims.height).toBe(280)
|
||||
})
|
||||
|
||||
it('should return correct dimensions for grid variant', () => {
|
||||
const dims = getCardDimensions('grid')
|
||||
expect(dims.flex).toBe(1)
|
||||
expect(dims.aspectRatio).toBe(0.75)
|
||||
})
|
||||
|
||||
it('should return correct dimensions for featured variant', () => {
|
||||
const dims = getCardDimensions('featured')
|
||||
expect(dims.width).toBe(320)
|
||||
expect(dims.height).toBe(400)
|
||||
})
|
||||
|
||||
it('should default to horizontal for unknown variant', () => {
|
||||
const dims = getCardDimensions('unknown' as CardVariant)
|
||||
expect(dims.width).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workout metadata', () => {
|
||||
const buildMetadata = (duration: number, calories: number, level: string): string => {
|
||||
return `${duration} MIN • ${calories} CAL • ${level.toUpperCase()}`
|
||||
}
|
||||
|
||||
it('should build correct metadata string', () => {
|
||||
expect(buildMetadata(4, 45, 'Beginner')).toBe('4 MIN • 45 CAL • BEGINNER')
|
||||
})
|
||||
|
||||
it('should handle different levels', () => {
|
||||
expect(buildMetadata(8, 90, 'Intermediate')).toBe('8 MIN • 90 CAL • INTERMEDIATE')
|
||||
expect(buildMetadata(20, 240, 'Advanced')).toBe('20 MIN • 240 CAL • ADVANCED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workout filtering helpers', () => {
|
||||
const workouts = [
|
||||
{ id: '1', category: 'full-body', level: 'Beginner', duration: 4 },
|
||||
{ id: '2', category: 'core', level: 'Intermediate', duration: 8 },
|
||||
{ id: '3', category: 'upper-body', level: 'Advanced', duration: 12 },
|
||||
{ id: '4', category: 'full-body', level: 'Intermediate', duration: 4 },
|
||||
]
|
||||
|
||||
const filterByCategory = (list: typeof workouts, cat: string) =>
|
||||
list.filter(w => w.category === cat)
|
||||
|
||||
const filterByLevel = (list: typeof workouts, lvl: string) =>
|
||||
list.filter(w => w.level === lvl)
|
||||
|
||||
const filterByDuration = (list: typeof workouts, dur: number) =>
|
||||
list.filter(w => w.duration === dur)
|
||||
|
||||
it('should filter workouts by category', () => {
|
||||
expect(filterByCategory(workouts, 'full-body')).toHaveLength(2)
|
||||
expect(filterByCategory(workouts, 'core')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should filter workouts by level', () => {
|
||||
expect(filterByLevel(workouts, 'Beginner')).toHaveLength(1)
|
||||
expect(filterByLevel(workouts, 'Intermediate')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should filter workouts by duration', () => {
|
||||
expect(filterByDuration(workouts, 4)).toHaveLength(2)
|
||||
expect(filterByDuration(workouts, 8)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
122
src/__tests__/components/rendering/CollectionCard.test.tsx
Normal file
122
src/__tests__/components/rendering/CollectionCard.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react-native'
|
||||
import { CollectionCard } from '@/src/shared/components/CollectionCard'
|
||||
import type { Collection } from '@/src/shared/types'
|
||||
|
||||
const mockCollection: Collection = {
|
||||
id: 'test-collection',
|
||||
title: 'Upper Body Blast',
|
||||
description: 'An intense upper body workout collection',
|
||||
icon: '💪',
|
||||
workoutIds: ['w1', 'w2', 'w3'],
|
||||
gradient: ['#FF6B35', '#FF3B30'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to recursively find a node in the rendered tree by type.
|
||||
*/
|
||||
function findByType(tree: any, typeName: string): any {
|
||||
if (!tree) return null
|
||||
if (tree.type === typeName) return tree
|
||||
if (tree.children && Array.isArray(tree.children)) {
|
||||
for (const child of tree.children) {
|
||||
if (typeof child === 'object') {
|
||||
const found = findByType(child, typeName)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe('CollectionCard', () => {
|
||||
it('renders collection title', () => {
|
||||
render(<CollectionCard collection={mockCollection} />)
|
||||
expect(screen.getByText('Upper Body Blast')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders workout count', () => {
|
||||
render(<CollectionCard collection={mockCollection} />)
|
||||
expect(screen.getByText('3 workouts')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders icon emoji', () => {
|
||||
render(<CollectionCard collection={mockCollection} />)
|
||||
expect(screen.getByText('💪')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls onPress when pressed', () => {
|
||||
const onPress = vi.fn()
|
||||
render(<CollectionCard collection={mockCollection} onPress={onPress} />)
|
||||
fireEvent.press(screen.getByText('Upper Body Blast'))
|
||||
expect(onPress).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders without onPress (no crash)', () => {
|
||||
const { toJSON } = render(<CollectionCard collection={mockCollection} />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders LinearGradient when no imageUrl', () => {
|
||||
const { toJSON } = render(<CollectionCard collection={mockCollection} />)
|
||||
expect(screen.getByTestId('linear-gradient')).toBeTruthy()
|
||||
// LinearGradient should receive the collection's gradient colors
|
||||
const gradientNode = findByType(toJSON(), 'LinearGradient')
|
||||
expect(gradientNode).toBeTruthy()
|
||||
expect(gradientNode.props.colors).toEqual(['#FF6B35', '#FF3B30'])
|
||||
})
|
||||
|
||||
it('renders ImageBackground when imageUrl is provided', () => {
|
||||
const { toJSON } = render(
|
||||
<CollectionCard
|
||||
collection={mockCollection}
|
||||
imageUrl="https://example.com/image.jpg"
|
||||
/>
|
||||
)
|
||||
// Should render ImageBackground instead of standalone LinearGradient
|
||||
const tree = toJSON()
|
||||
const imageBackground = findByType(tree, 'ImageBackground')
|
||||
expect(imageBackground).toBeTruthy()
|
||||
expect(imageBackground.props.source).toEqual({ uri: 'https://example.com/image.jpg' })
|
||||
})
|
||||
|
||||
it('uses default gradient colors when collection has no gradient', () => {
|
||||
const collectionNoGradient: Collection = {
|
||||
...mockCollection,
|
||||
gradient: undefined,
|
||||
}
|
||||
const { toJSON } = render(
|
||||
<CollectionCard collection={collectionNoGradient} />
|
||||
)
|
||||
// Should use fallback gradient: [BRAND.PRIMARY, '#FF3B30']
|
||||
const gradientNode = findByType(toJSON(), 'LinearGradient')
|
||||
expect(gradientNode).toBeTruthy()
|
||||
// BRAND.PRIMARY = '#FF6B35' from constants
|
||||
expect(gradientNode.props.colors).toEqual(['#FF6B35', '#FF3B30'])
|
||||
})
|
||||
|
||||
it('renders blur overlay', () => {
|
||||
render(<CollectionCard collection={mockCollection} />)
|
||||
expect(screen.getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('handles empty workoutIds', () => {
|
||||
const emptyCollection: Collection = {
|
||||
...mockCollection,
|
||||
workoutIds: [],
|
||||
}
|
||||
render(<CollectionCard collection={emptyCollection} />)
|
||||
expect(screen.getByText('0 workouts')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('snapshot with imageUrl (different rendering path)', () => {
|
||||
const { toJSON } = render(
|
||||
<CollectionCard
|
||||
collection={mockCollection}
|
||||
imageUrl="https://example.com/image.jpg"
|
||||
/>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
115
src/__tests__/components/rendering/DataDeletionModal.test.tsx
Normal file
115
src/__tests__/components/rendering/DataDeletionModal.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react-native'
|
||||
import { DataDeletionModal } from '@/src/shared/components/DataDeletionModal'
|
||||
|
||||
describe('DataDeletionModal', () => {
|
||||
const defaultProps = {
|
||||
visible: true,
|
||||
onDelete: vi.fn().mockResolvedValue(undefined),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders when visible is true', () => {
|
||||
render(<DataDeletionModal {...defaultProps} />)
|
||||
// Title key from i18n mock
|
||||
expect(screen.getByText('dataDeletion.title')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders warning icon', () => {
|
||||
render(<DataDeletionModal {...defaultProps} />)
|
||||
expect(screen.getByTestId('icon-warning')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders description and note text', () => {
|
||||
render(<DataDeletionModal {...defaultProps} />)
|
||||
expect(screen.getByText('dataDeletion.description')).toBeTruthy()
|
||||
expect(screen.getByText('dataDeletion.note')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders delete and cancel buttons', () => {
|
||||
render(<DataDeletionModal {...defaultProps} />)
|
||||
expect(screen.getByText('dataDeletion.deleteButton')).toBeTruthy()
|
||||
expect(screen.getByText('dataDeletion.cancelButton')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is pressed', () => {
|
||||
render(<DataDeletionModal {...defaultProps} />)
|
||||
fireEvent.press(screen.getByText('dataDeletion.cancelButton'))
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onDelete when delete button is pressed', async () => {
|
||||
render(<DataDeletionModal {...defaultProps} />)
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
|
||||
})
|
||||
expect(defaultProps.onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows loading text while deleting', async () => {
|
||||
let resolveDelete: () => void
|
||||
const slowDelete = new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve
|
||||
})
|
||||
const onDelete = vi.fn(() => slowDelete)
|
||||
|
||||
render(<DataDeletionModal visible={true} onDelete={onDelete} onCancel={vi.fn()} />)
|
||||
|
||||
// Start delete
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
|
||||
})
|
||||
|
||||
// Should show 'Deleting...' while in progress
|
||||
expect(screen.getByText('Deleting...')).toBeTruthy()
|
||||
|
||||
// Complete the delete
|
||||
await act(async () => {
|
||||
resolveDelete!()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not render content when visible is false', () => {
|
||||
render(<DataDeletionModal {...defaultProps} visible={false} />)
|
||||
// Modal with visible=false won't render its children
|
||||
expect(screen.queryByText('dataDeletion.title')).toBeNull()
|
||||
})
|
||||
|
||||
it('full modal structure snapshot', () => {
|
||||
const { toJSON } = render(<DataDeletionModal {...defaultProps} />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('delete button shows disabled state while deleting', async () => {
|
||||
let resolveDelete: () => void
|
||||
const slowDelete = new Promise<void>((resolve) => {
|
||||
resolveDelete = resolve
|
||||
})
|
||||
const onDelete = vi.fn(() => slowDelete)
|
||||
|
||||
const { toJSON } = render(
|
||||
<DataDeletionModal visible={true} onDelete={onDelete} onCancel={vi.fn()} />
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('dataDeletion.deleteButton'))
|
||||
})
|
||||
|
||||
// While deleting, the button text changes to loading state
|
||||
expect(screen.getByText('Deleting...')).toBeTruthy()
|
||||
|
||||
// Verify the tree has the disabled styling applied (opacity: 0.6)
|
||||
const tree = toJSON()
|
||||
const treeStr = JSON.stringify(tree)
|
||||
expect(treeStr).toContain('"opacity":0.6')
|
||||
|
||||
await act(async () => {
|
||||
resolveDelete!()
|
||||
})
|
||||
})
|
||||
})
|
||||
154
src/__tests__/components/rendering/GlassCard.test.tsx
Normal file
154
src/__tests__/components/rendering/GlassCard.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react-native'
|
||||
import { Text } from 'react-native'
|
||||
import { GlassCard, GlassCardElevated, GlassCardInset, GlassCardTinted } from '@/src/shared/components/GlassCard'
|
||||
|
||||
describe('GlassCard', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text testID="child">Hello</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.getByTestId('child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders BlurView when hasBlur is true (default)', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not render BlurView when hasBlur is false', () => {
|
||||
render(
|
||||
<GlassCard hasBlur={false}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(screen.queryByTestId('blur-view')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders with custom blurIntensity', () => {
|
||||
render(
|
||||
<GlassCard blurIntensity={80}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
const blurView = screen.getByTestId('blur-view')
|
||||
expect(blurView.props.intensity).toBe(80)
|
||||
})
|
||||
|
||||
it('uses theme blurMedium when blurIntensity is not provided', () => {
|
||||
render(
|
||||
<GlassCard>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
const blurView = screen.getByTestId('blur-view')
|
||||
// from mock: colors.glass.blurMedium = 40
|
||||
expect(blurView.props.intensity).toBe(40)
|
||||
})
|
||||
|
||||
it('applies custom style prop to root container', () => {
|
||||
const customStyle = { padding: 20 }
|
||||
const { toJSON } = render(
|
||||
<GlassCard style={customStyle}>
|
||||
<Text>Content</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
const tree = toJSON()
|
||||
// Root View should have the custom style merged into its style array
|
||||
const rootStyle = tree?.props?.style
|
||||
expect(rootStyle).toBeDefined()
|
||||
// Style is an array — flatten and check custom style is present
|
||||
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
|
||||
const hasPadding = flatStyles.some(
|
||||
(s: any) => s && typeof s === 'object' && s.padding === 20
|
||||
)
|
||||
expect(hasPadding).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlassCard variants', () => {
|
||||
it('renders base variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard>
|
||||
<Text>Base</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders elevated variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="elevated">
|
||||
<Text>Elevated</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders inset variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="inset">
|
||||
<Text>Inset</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders tinted variant (snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCard variant="tinted">
|
||||
<Text>Tinted</Text>
|
||||
</GlassCard>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GlassCard presets', () => {
|
||||
it('GlassCardElevated renders with blur and children', () => {
|
||||
const { getByTestId } = render(
|
||||
<GlassCardElevated>
|
||||
<Text testID="elevated-child">Elevated</Text>
|
||||
</GlassCardElevated>
|
||||
)
|
||||
expect(getByTestId('elevated-child')).toBeTruthy()
|
||||
expect(getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardInset renders WITHOUT blur (hasBlur=false)', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<GlassCardInset>
|
||||
<Text testID="inset-child">Inset</Text>
|
||||
</GlassCardInset>
|
||||
)
|
||||
expect(getByTestId('inset-child')).toBeTruthy()
|
||||
// GlassCardInset passes hasBlur={false} — this is the key behavioral assertion
|
||||
expect(queryByTestId('blur-view')).toBeNull()
|
||||
})
|
||||
|
||||
it('GlassCardTinted renders with blur', () => {
|
||||
const { getByTestId } = render(
|
||||
<GlassCardTinted>
|
||||
<Text testID="tinted-child">Tinted</Text>
|
||||
</GlassCardTinted>
|
||||
)
|
||||
expect(getByTestId('tinted-child')).toBeTruthy()
|
||||
expect(getByTestId('blur-view')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('GlassCardElevated snapshot', () => {
|
||||
const { toJSON } = render(
|
||||
<GlassCardElevated>
|
||||
<Text>Elevated preset</Text>
|
||||
</GlassCardElevated>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
123
src/__tests__/components/rendering/OnboardingStep.test.tsx
Normal file
123
src/__tests__/components/rendering/OnboardingStep.test.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react-native'
|
||||
import { Text } from 'react-native'
|
||||
import { OnboardingStep } from '@/src/shared/components/OnboardingStep'
|
||||
|
||||
/**
|
||||
* Helper to recursively find a node in the rendered tree by its element type name.
|
||||
* Returns the first match or null.
|
||||
*/
|
||||
function findByType(tree: any, typeName: string): any {
|
||||
if (!tree) return null
|
||||
if (tree.type === typeName) return tree
|
||||
if (tree.children && Array.isArray(tree.children)) {
|
||||
for (const child of tree.children) {
|
||||
if (typeof child === 'object') {
|
||||
const found = findByType(child, typeName)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to count nodes of a given type in the tree
|
||||
*/
|
||||
function countByType(tree: any, typeName: string): number {
|
||||
if (!tree) return 0
|
||||
let count = tree.type === typeName ? 1 : 0
|
||||
if (tree.children && Array.isArray(tree.children)) {
|
||||
for (const child of tree.children) {
|
||||
if (typeof child === 'object') {
|
||||
count += countByType(child, typeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
describe('OnboardingStep', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<OnboardingStep step={1} totalSteps={6}>
|
||||
<Text testID="child-content">Welcome</Text>
|
||||
</OnboardingStep>
|
||||
)
|
||||
expect(screen.getByTestId('child-content')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders progress bar with track and fill Views', () => {
|
||||
const { toJSON } = render(
|
||||
<OnboardingStep step={1} totalSteps={6}>
|
||||
<Text>Step 1</Text>
|
||||
</OnboardingStep>
|
||||
)
|
||||
const tree = toJSON()
|
||||
// OnboardingStep should have:
|
||||
// - A root View (container)
|
||||
// - A View (progressTrack)
|
||||
// - An Animated.View (progressFill) — rendered as View by mock
|
||||
// - An Animated.View (content wrapper)
|
||||
expect(tree).toBeTruthy()
|
||||
expect(tree?.type).toBe('View') // root container
|
||||
expect(tree?.children).toBeDefined()
|
||||
expect(tree!.children!.length).toBeGreaterThanOrEqual(2) // progress track + content
|
||||
})
|
||||
|
||||
it('step 1 of 6 snapshot', () => {
|
||||
const { toJSON } = render(
|
||||
<OnboardingStep step={1} totalSteps={6}>
|
||||
<Text>First step</Text>
|
||||
</OnboardingStep>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('step 6 of 6 (final step) snapshot', () => {
|
||||
const { toJSON } = render(
|
||||
<OnboardingStep step={6} totalSteps={6}>
|
||||
<Text>Final step</Text>
|
||||
</OnboardingStep>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders multiple children inside content area', () => {
|
||||
render(
|
||||
<OnboardingStep step={3} totalSteps={6}>
|
||||
<Text testID="title">Title</Text>
|
||||
<Text testID="description">Description</Text>
|
||||
</OnboardingStep>
|
||||
)
|
||||
expect(screen.getByTestId('title')).toBeTruthy()
|
||||
expect(screen.getByTestId('description')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not crash with step 0 (edge case snapshot)', () => {
|
||||
const { toJSON } = render(
|
||||
<OnboardingStep step={0} totalSteps={6}>
|
||||
<Text>Edge case</Text>
|
||||
</OnboardingStep>
|
||||
)
|
||||
// Should render without error — snapshot captures structure
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('container uses safe area top inset for paddingTop', () => {
|
||||
const { toJSON } = render(
|
||||
<OnboardingStep step={1} totalSteps={6}>
|
||||
<Text>Check padding</Text>
|
||||
</OnboardingStep>
|
||||
)
|
||||
const tree = toJSON()
|
||||
// Root container should have paddingTop accounting for safe area (mock returns top=47)
|
||||
const rootStyle = tree?.props?.style
|
||||
const flatStyles = Array.isArray(rootStyle) ? rootStyle : [rootStyle]
|
||||
const hasPaddingTop = flatStyles.some(
|
||||
(s: any) => s && typeof s === 'object' && typeof s.paddingTop === 'number' && s.paddingTop > 0
|
||||
)
|
||||
expect(hasPaddingTop).toBe(true)
|
||||
})
|
||||
})
|
||||
177
src/__tests__/components/rendering/Skeleton.test.tsx
Normal file
177
src/__tests__/components/rendering/Skeleton.test.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react-native'
|
||||
import {
|
||||
Skeleton,
|
||||
WorkoutCardSkeleton,
|
||||
TrainerCardSkeleton,
|
||||
CollectionCardSkeleton,
|
||||
StatsCardSkeleton,
|
||||
} from '@/src/shared/components/loading/Skeleton'
|
||||
|
||||
/**
|
||||
* Helper to extract the flattened style from a rendered tree node.
|
||||
* Style can be a single object or an array of objects.
|
||||
*/
|
||||
function flattenStyle(style: any): Record<string, any> {
|
||||
if (!style) return {}
|
||||
if (Array.isArray(style)) {
|
||||
return Object.assign({}, ...style.filter(Boolean))
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders with default dimensions (snapshot)', () => {
|
||||
const { toJSON } = render(<Skeleton />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('applies default width=100% and height=20', () => {
|
||||
const { toJSON } = render(<Skeleton />)
|
||||
const tree = toJSON()
|
||||
const style = flattenStyle(tree?.props?.style)
|
||||
expect(style.width).toBe('100%')
|
||||
expect(style.height).toBe(20)
|
||||
})
|
||||
|
||||
it('applies custom width and height', () => {
|
||||
const { toJSON } = render(<Skeleton width={200} height={40} />)
|
||||
const tree = toJSON()
|
||||
const style = flattenStyle(tree?.props?.style)
|
||||
expect(style.width).toBe(200)
|
||||
expect(style.height).toBe(40)
|
||||
})
|
||||
|
||||
it('applies percentage width', () => {
|
||||
const { toJSON } = render(<Skeleton width="70%" height={20} />)
|
||||
const tree = toJSON()
|
||||
const style = flattenStyle(tree?.props?.style)
|
||||
expect(style.width).toBe('70%')
|
||||
expect(style.height).toBe(20)
|
||||
})
|
||||
|
||||
it('applies custom borderRadius', () => {
|
||||
const { toJSON } = render(<Skeleton borderRadius={40} />)
|
||||
const tree = toJSON()
|
||||
const style = flattenStyle(tree?.props?.style)
|
||||
expect(style.borderRadius).toBe(40)
|
||||
})
|
||||
|
||||
it('merges custom style prop', () => {
|
||||
const customStyle = { marginTop: 10 }
|
||||
const { toJSON } = render(<Skeleton style={customStyle} />)
|
||||
const tree = toJSON()
|
||||
const style = flattenStyle(tree?.props?.style)
|
||||
expect(style.marginTop).toBe(10)
|
||||
// Should still have default dimensions
|
||||
expect(style.width).toBe('100%')
|
||||
expect(style.height).toBe(20)
|
||||
})
|
||||
|
||||
it('renders shimmer overlay as a child element', () => {
|
||||
const { toJSON } = render(<Skeleton />)
|
||||
const tree = toJSON()
|
||||
// Root View should have at least one child (the shimmer Animated.View)
|
||||
expect(tree?.children).toBeDefined()
|
||||
expect(tree!.children!.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('uses theme overlay color for background', () => {
|
||||
const { toJSON } = render(<Skeleton />)
|
||||
const tree = toJSON()
|
||||
// The Skeleton renders a View with style array including backgroundColor.
|
||||
// Walk the style array (may be nested) to find backgroundColor.
|
||||
function findBackgroundColor(node: any): string | undefined {
|
||||
if (!node?.props?.style) return undefined
|
||||
const style = flattenStyle(node.props.style)
|
||||
if (style.backgroundColor) return style.backgroundColor
|
||||
// Check children
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
if (typeof child === 'object') {
|
||||
const found = findBackgroundColor(child)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const bgColor = findBackgroundColor(tree)
|
||||
expect(bgColor).toBeDefined()
|
||||
expect(typeof bgColor).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkoutCardSkeleton', () => {
|
||||
it('renders correct structure (snapshot)', () => {
|
||||
const { toJSON } = render(<WorkoutCardSkeleton />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('contains multiple Skeleton elements as children', () => {
|
||||
const { toJSON } = render(<WorkoutCardSkeleton />)
|
||||
const tree = toJSON()
|
||||
// WorkoutCardSkeleton has: image skeleton + title skeleton + row with 2 skeletons = 4 total
|
||||
// Count all View nodes (Skeleton renders as View)
|
||||
function countViews(node: any): number {
|
||||
if (!node) return 0
|
||||
let count = node.type === 'View' ? 1 : 0
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
if (typeof child === 'object') count += countViews(child)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
// Should have at least 5 View nodes (card container + skeletons + content wrapper + row)
|
||||
expect(countViews(tree)).toBeGreaterThanOrEqual(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrainerCardSkeleton', () => {
|
||||
it('renders correct structure (snapshot)', () => {
|
||||
const { toJSON } = render(<TrainerCardSkeleton />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('contains circular avatar skeleton (borderRadius=40)', () => {
|
||||
const { toJSON } = render(<TrainerCardSkeleton />)
|
||||
// First Skeleton inside is the avatar: width=80, height=80, borderRadius=40
|
||||
function findCircleSkeleton(node: any): boolean {
|
||||
if (!node) return false
|
||||
if (node.type === 'View') {
|
||||
const style = flattenStyle(node.props?.style)
|
||||
if (style.width === 80 && style.height === 80 && style.borderRadius === 40) return true
|
||||
}
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
return node.children.some((child: any) => typeof child === 'object' && findCircleSkeleton(child))
|
||||
}
|
||||
return false
|
||||
}
|
||||
expect(findCircleSkeleton(toJSON())).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CollectionCardSkeleton', () => {
|
||||
it('renders correct structure (snapshot)', () => {
|
||||
const { toJSON } = render(<CollectionCardSkeleton />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatsCardSkeleton', () => {
|
||||
it('renders correct structure (snapshot)', () => {
|
||||
const { toJSON } = render(<StatsCardSkeleton />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('contains header row with two skeleton elements', () => {
|
||||
const { toJSON } = render(<StatsCardSkeleton />)
|
||||
const tree = toJSON()
|
||||
// StatsCardSkeleton has: card > statsHeader (row) + large skeleton
|
||||
// statsHeader has 2 children (title skeleton + icon skeleton)
|
||||
expect(tree?.children).toBeDefined()
|
||||
expect(tree!.children!.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
125
src/__tests__/components/rendering/SyncConsentModal.test.tsx
Normal file
125
src/__tests__/components/rendering/SyncConsentModal.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react-native'
|
||||
import { SyncConsentModal } from '@/src/shared/components/SyncConsentModal'
|
||||
|
||||
describe('SyncConsentModal', () => {
|
||||
const defaultProps = {
|
||||
visible: true,
|
||||
onAccept: vi.fn().mockResolvedValue(undefined),
|
||||
onDecline: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders when visible is true', () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
expect(screen.getByText('sync.title')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders sparkles icon', () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
expect(screen.getByTestId('icon-sparkles')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders benefit rows', () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
expect(screen.getByText('sync.benefits.recommendations')).toBeTruthy()
|
||||
expect(screen.getByText('sync.benefits.adaptive')).toBeTruthy()
|
||||
expect(screen.getByText('sync.benefits.sync')).toBeTruthy()
|
||||
expect(screen.getByText('sync.benefits.secure')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders benefit icons', () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
expect(screen.getByTestId('icon-trending-up')).toBeTruthy()
|
||||
expect(screen.getByTestId('icon-fitness')).toBeTruthy()
|
||||
expect(screen.getByTestId('icon-sync')).toBeTruthy()
|
||||
expect(screen.getByTestId('icon-shield-checkmark')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders privacy note', () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
expect(screen.getByText('sync.privacy')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders primary and secondary buttons', () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
expect(screen.getByText('sync.primaryButton')).toBeTruthy()
|
||||
expect(screen.getByText('sync.secondaryButton')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls onDecline when secondary button is pressed', () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
fireEvent.press(screen.getByText('sync.secondaryButton'))
|
||||
expect(defaultProps.onDecline).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onAccept when primary button is pressed', async () => {
|
||||
render(<SyncConsentModal {...defaultProps} />)
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('sync.primaryButton'))
|
||||
})
|
||||
expect(defaultProps.onAccept).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows loading text while accepting', async () => {
|
||||
let resolveAccept: () => void
|
||||
const slowAccept = new Promise<void>((resolve) => {
|
||||
resolveAccept = resolve
|
||||
})
|
||||
const onAccept = vi.fn(() => slowAccept)
|
||||
|
||||
render(<SyncConsentModal visible={true} onAccept={onAccept} onDecline={vi.fn()} />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('sync.primaryButton'))
|
||||
})
|
||||
|
||||
expect(screen.getByText('Setting up...')).toBeTruthy()
|
||||
|
||||
await act(async () => {
|
||||
resolveAccept!()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not render content when visible is false', () => {
|
||||
render(<SyncConsentModal {...defaultProps} visible={false} />)
|
||||
expect(screen.queryByText('sync.title')).toBeNull()
|
||||
})
|
||||
|
||||
it('full modal structure snapshot', () => {
|
||||
const { toJSON } = render(<SyncConsentModal {...defaultProps} />)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('primary button shows disabled state while loading', async () => {
|
||||
let resolveAccept: () => void
|
||||
const slowAccept = new Promise<void>((resolve) => {
|
||||
resolveAccept = resolve
|
||||
})
|
||||
const onAccept = vi.fn(() => slowAccept)
|
||||
|
||||
const { toJSON } = render(
|
||||
<SyncConsentModal visible={true} onAccept={onAccept} onDecline={vi.fn()} />
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.press(screen.getByText('sync.primaryButton'))
|
||||
})
|
||||
|
||||
// While loading, button text changes to loading state
|
||||
expect(screen.getByText('Setting up...')).toBeTruthy()
|
||||
|
||||
// Verify the tree has the disabled styling applied (opacity: 0.6)
|
||||
const tree = toJSON()
|
||||
const treeStr = JSON.stringify(tree)
|
||||
expect(treeStr).toContain('"opacity":0.6')
|
||||
|
||||
await act(async () => {
|
||||
resolveAccept!()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react-native'
|
||||
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
|
||||
|
||||
describe('VideoPlayer rendering', () => {
|
||||
describe('preview mode', () => {
|
||||
it('renders gradient fallback when no videoUrl', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer mode="preview" isPlaying={false} />
|
||||
)
|
||||
const tree = toJSON()
|
||||
expect(tree).toBeTruthy()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders video view when videoUrl is provided', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
|
||||
mode="preview"
|
||||
isPlaying={true}
|
||||
/>
|
||||
)
|
||||
const tree = toJSON()
|
||||
expect(tree).toBeTruthy()
|
||||
expect(tree).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with custom style', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
mode="preview"
|
||||
style={{ height: 220, borderRadius: 20 }}
|
||||
/>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders with testID prop', () => {
|
||||
const { getByTestId } = render(
|
||||
<VideoPlayer
|
||||
mode="preview"
|
||||
testID="my-video-player"
|
||||
/>
|
||||
)
|
||||
expect(getByTestId('my-video-player')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('background mode', () => {
|
||||
it('renders gradient fallback when no videoUrl', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer mode="background" isPlaying={false} />
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('renders video view when videoUrl is provided', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
videoUrl="https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8"
|
||||
mode="background"
|
||||
isPlaying={true}
|
||||
/>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom gradient colors', () => {
|
||||
it('renders with custom gradient colors when no video', () => {
|
||||
const { toJSON } = render(
|
||||
<VideoPlayer
|
||||
gradientColors={['#FF0000', '#0000FF']}
|
||||
mode="preview"
|
||||
/>
|
||||
)
|
||||
expect(toJSON()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CollectionCard > renders without onPress (no crash) 1`] = `
|
||||
<Pressable
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"aspectRatio": 1,
|
||||
"borderRadius": 20,
|
||||
"overflow": "hidden",
|
||||
"shadowColor": "#000",
|
||||
"shadowOffset": {
|
||||
"height": 2,
|
||||
"width": 0,
|
||||
},
|
||||
"shadowOpacity": 0.25,
|
||||
"shadowRadius": 4,
|
||||
"width": 157.5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF6B35",
|
||||
"#FF3B30",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
},
|
||||
{
|
||||
"borderRadius": 20,
|
||||
},
|
||||
]
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "rgba(0,0,0,0.3)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<BlurView
|
||||
intensity={20}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="blur-view"
|
||||
tint="dark"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"justifyContent": "flex-end",
|
||||
"padding": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "rgba(255,255,255,0.15)",
|
||||
"borderColor": "rgba(255,255,255,0.2)",
|
||||
"borderRadius": 14,
|
||||
"borderWidth": 1,
|
||||
"height": 48,
|
||||
"justifyContent": "center",
|
||||
"marginBottom": 12,
|
||||
"width": 48,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"fontSize": 24,
|
||||
}
|
||||
}
|
||||
>
|
||||
💪
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"fontSize": 17,
|
||||
"fontWeight": "700",
|
||||
},
|
||||
{
|
||||
"marginBottom": 4,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Upper Body Blast
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "rgba(255,255,255,0.7)",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
3
|
||||
workouts
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
`;
|
||||
|
||||
exports[`CollectionCard > snapshot with imageUrl (different rendering path) 1`] = `
|
||||
<Pressable
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"aspectRatio": 1,
|
||||
"borderRadius": 20,
|
||||
"overflow": "hidden",
|
||||
"shadowColor": "#000",
|
||||
"shadowOffset": {
|
||||
"height": 2,
|
||||
"width": 0,
|
||||
},
|
||||
"shadowOpacity": 0.25,
|
||||
"shadowRadius": 4,
|
||||
"width": 157.5,
|
||||
}
|
||||
}
|
||||
>
|
||||
<ImageBackground
|
||||
imageStyle={
|
||||
{
|
||||
"borderRadius": 20,
|
||||
}
|
||||
}
|
||||
ref={null}
|
||||
resizeMode="cover"
|
||||
source={
|
||||
{
|
||||
"uri": "https://example.com/image.jpg",
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"transparent",
|
||||
"rgba(0,0,0,0.8)",
|
||||
]
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</ImageBackground>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "rgba(0,0,0,0.3)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
>
|
||||
<BlurView
|
||||
intensity={20}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="blur-view"
|
||||
tint="dark"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"justifyContent": "flex-end",
|
||||
"padding": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"backgroundColor": "rgba(255,255,255,0.15)",
|
||||
"borderColor": "rgba(255,255,255,0.2)",
|
||||
"borderRadius": 14,
|
||||
"borderWidth": 1,
|
||||
"height": 48,
|
||||
"justifyContent": "center",
|
||||
"marginBottom": 12,
|
||||
"width": 48,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"fontSize": 24,
|
||||
}
|
||||
}
|
||||
>
|
||||
💪
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"fontSize": 17,
|
||||
"fontWeight": "700",
|
||||
},
|
||||
{
|
||||
"marginBottom": 4,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
Upper Body Blast
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"color": "rgba(255,255,255,0.7)",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "500",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
3
|
||||
workouts
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
`;
|
||||
@@ -0,0 +1,283 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`GlassCard presets > GlassCardElevated snapshot 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"borderRadius": 24,
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.08)",
|
||||
"borderColor": "rgba(255, 255, 255, 0.12)",
|
||||
"borderWidth": 1,
|
||||
},
|
||||
{
|
||||
"shadowColor": "#000",
|
||||
"shadowOffset": {
|
||||
"height": 2,
|
||||
"width": 0,
|
||||
},
|
||||
"shadowOpacity": 0.25,
|
||||
"shadowRadius": 4,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="blur-view"
|
||||
tint="dark"
|
||||
/>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
Elevated preset
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`GlassCard variants > renders base variant (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"borderRadius": 24,
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.05)",
|
||||
"borderColor": "rgba(255, 255, 255, 0.1)",
|
||||
"borderWidth": 1,
|
||||
},
|
||||
{
|
||||
"shadowColor": "#000",
|
||||
"shadowOffset": {
|
||||
"height": 1,
|
||||
"width": 0,
|
||||
},
|
||||
"shadowOpacity": 0.2,
|
||||
"shadowRadius": 2,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="blur-view"
|
||||
tint="dark"
|
||||
/>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
Base
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`GlassCard variants > renders elevated variant (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"borderRadius": 24,
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.08)",
|
||||
"borderColor": "rgba(255, 255, 255, 0.12)",
|
||||
"borderWidth": 1,
|
||||
},
|
||||
{
|
||||
"shadowColor": "#000",
|
||||
"shadowOffset": {
|
||||
"height": 2,
|
||||
"width": 0,
|
||||
},
|
||||
"shadowOpacity": 0.25,
|
||||
"shadowRadius": 4,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="blur-view"
|
||||
tint="dark"
|
||||
/>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
Elevated
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`GlassCard variants > renders inset variant (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"borderRadius": 24,
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(0, 0, 0, 0.2)",
|
||||
"borderColor": "rgba(255, 255, 255, 0.05)",
|
||||
"borderWidth": 1,
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="blur-view"
|
||||
tint="dark"
|
||||
/>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
Inset
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`GlassCard variants > renders tinted variant (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"borderRadius": 24,
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "rgba(255, 107, 53, 0.1)",
|
||||
"borderColor": "rgba(255, 107, 53, 0.2)",
|
||||
"borderWidth": 1,
|
||||
},
|
||||
{
|
||||
"shadowColor": "#000",
|
||||
"shadowOffset": {
|
||||
"height": 1,
|
||||
"width": 0,
|
||||
},
|
||||
"shadowOpacity": 0.2,
|
||||
"shadowRadius": 2,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<BlurView
|
||||
intensity={40}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="blur-view"
|
||||
tint="dark"
|
||||
/>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
Tinted
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
@@ -0,0 +1,277 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`OnboardingStep > does not crash with step 0 (edge case snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000000",
|
||||
"flex": 1,
|
||||
},
|
||||
{
|
||||
"paddingTop": 59,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
"borderRadius": 2,
|
||||
"height": 3,
|
||||
"marginHorizontal": 24,
|
||||
"overflow": "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#FF6B35",
|
||||
"borderRadius": 2,
|
||||
"height": "100%",
|
||||
},
|
||||
{
|
||||
"width": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
"0%",
|
||||
"100%",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"flex": 1,
|
||||
"paddingHorizontal": 24,
|
||||
"paddingTop": 32,
|
||||
},
|
||||
{
|
||||
"opacity": AnimatedValue {
|
||||
"_listeners": Map {},
|
||||
"_value": 0,
|
||||
},
|
||||
"transform": [
|
||||
{
|
||||
"translateX": AnimatedValue {
|
||||
"_listeners": Map {},
|
||||
"_value": 375,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"paddingBottom": 58,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
Edge case
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`OnboardingStep > step 1 of 6 snapshot 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000000",
|
||||
"flex": 1,
|
||||
},
|
||||
{
|
||||
"paddingTop": 59,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
"borderRadius": 2,
|
||||
"height": 3,
|
||||
"marginHorizontal": 24,
|
||||
"overflow": "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#FF6B35",
|
||||
"borderRadius": 2,
|
||||
"height": "100%",
|
||||
},
|
||||
{
|
||||
"width": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
"0%",
|
||||
"100%",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"flex": 1,
|
||||
"paddingHorizontal": 24,
|
||||
"paddingTop": 32,
|
||||
},
|
||||
{
|
||||
"opacity": AnimatedValue {
|
||||
"_listeners": Map {},
|
||||
"_value": 0,
|
||||
},
|
||||
"transform": [
|
||||
{
|
||||
"translateX": AnimatedValue {
|
||||
"_listeners": Map {},
|
||||
"_value": 375,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"paddingBottom": 58,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
First step
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`OnboardingStep > step 6 of 6 (final step) snapshot 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000000",
|
||||
"flex": 1,
|
||||
},
|
||||
{
|
||||
"paddingTop": 59,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
"borderRadius": 2,
|
||||
"height": 3,
|
||||
"marginHorizontal": 24,
|
||||
"overflow": "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#FF6B35",
|
||||
"borderRadius": 2,
|
||||
"height": "100%",
|
||||
},
|
||||
{
|
||||
"width": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
"0%",
|
||||
"100%",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"flex": 1,
|
||||
"paddingHorizontal": 24,
|
||||
"paddingTop": 32,
|
||||
},
|
||||
{
|
||||
"opacity": AnimatedValue {
|
||||
"_listeners": Map {},
|
||||
"_value": 0,
|
||||
},
|
||||
"transform": [
|
||||
{
|
||||
"translateX": AnimatedValue {
|
||||
"_listeners": Map {},
|
||||
"_value": 375,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"paddingBottom": 58,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
ref={null}
|
||||
>
|
||||
Final step
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
`;
|
||||
@@ -0,0 +1,799 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CollectionCardSkeleton > renders correct structure (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"padding": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 20,
|
||||
"height": 120,
|
||||
"width": 120,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 18,
|
||||
"width": "80%",
|
||||
},
|
||||
{
|
||||
"marginTop": 12,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`Skeleton > renders with default dimensions (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 20,
|
||||
"width": "100%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`StatsCardSkeleton > renders correct structure (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"borderRadius": 16,
|
||||
"minWidth": 140,
|
||||
"padding": 16,
|
||||
},
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 14,
|
||||
"width": "60%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 24,
|
||||
"width": 24,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 32,
|
||||
"width": "50%",
|
||||
},
|
||||
{
|
||||
"marginTop": 8,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`TrainerCardSkeleton > renders correct structure (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"alignItems": "center",
|
||||
"borderRadius": 16,
|
||||
"flexDirection": "row",
|
||||
"marginBottom": 12,
|
||||
"padding": 16,
|
||||
},
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 40,
|
||||
"height": 80,
|
||||
"width": 80,
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flex": 1,
|
||||
"gap": 8,
|
||||
"marginLeft": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 18,
|
||||
"width": "80%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 14,
|
||||
"width": "60%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`WorkoutCardSkeleton > renders correct structure (snapshot) 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"borderRadius": 16,
|
||||
"marginBottom": 16,
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": "#1C1C1E",
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 16,
|
||||
"height": 160,
|
||||
"width": "100%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"gap": 8,
|
||||
"padding": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 20,
|
||||
"width": "70%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
{
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
"marginTop": 8,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 16,
|
||||
"width": "40%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"backgroundColor": undefined,
|
||||
"borderRadius": 12,
|
||||
"height": 16,
|
||||
"width": "30%",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Animated.View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "rgba(255, 255, 255, 0.1)",
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"transform": [
|
||||
{
|
||||
"translateX": {
|
||||
"__isAnimatedInterpolation": true,
|
||||
"inputRange": [
|
||||
0,
|
||||
1,
|
||||
],
|
||||
"interpolate": [Function],
|
||||
"outputRange": [
|
||||
-200,
|
||||
200,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
@@ -0,0 +1,292 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`VideoPlayer rendering > background mode > renders gradient fallback when no videoUrl 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF6B35",
|
||||
"#E55A25",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > background mode > renders video view when videoUrl is provided 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<VideoView
|
||||
contentFit="cover"
|
||||
nativeControls={false}
|
||||
player={
|
||||
{
|
||||
"currentTime": 0,
|
||||
"duration": 100,
|
||||
"muted": false,
|
||||
"pause": [MockFunction],
|
||||
"play": [MockFunction] {
|
||||
"calls": [
|
||||
[],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"playing": false,
|
||||
"replace": [MockFunction],
|
||||
"volume": 1,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="video-view"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > custom gradient colors > renders with custom gradient colors when no video 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF0000",
|
||||
"#0000FF",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > preview mode > renders gradient fallback when no videoUrl 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF6B35",
|
||||
"#E55A25",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > preview mode > renders video view when videoUrl is provided 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<VideoView
|
||||
contentFit="cover"
|
||||
nativeControls={false}
|
||||
player={
|
||||
{
|
||||
"currentTime": 0,
|
||||
"duration": 100,
|
||||
"muted": false,
|
||||
"pause": [MockFunction],
|
||||
"play": [MockFunction] {
|
||||
"calls": [
|
||||
[],
|
||||
],
|
||||
"results": [
|
||||
{
|
||||
"type": "return",
|
||||
"value": undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
"playing": false,
|
||||
"replace": [MockFunction],
|
||||
"volume": 1,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="video-view"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`VideoPlayer rendering > preview mode > renders with custom style 1`] = `
|
||||
<View
|
||||
ref={null}
|
||||
style={
|
||||
[
|
||||
{
|
||||
"backgroundColor": "#000",
|
||||
"overflow": "hidden",
|
||||
},
|
||||
{
|
||||
"borderRadius": 20,
|
||||
"height": 220,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={
|
||||
[
|
||||
"#FF6B35",
|
||||
"#E55A25",
|
||||
]
|
||||
}
|
||||
end={
|
||||
{
|
||||
"x": 1,
|
||||
"y": 1,
|
||||
}
|
||||
}
|
||||
start={
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
}
|
||||
}
|
||||
style={
|
||||
{
|
||||
"bottom": 0,
|
||||
"left": 0,
|
||||
"position": "absolute",
|
||||
"right": 0,
|
||||
"top": 0,
|
||||
}
|
||||
}
|
||||
testID="linear-gradient"
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
182
src/__tests__/data/achievements.test.ts
Normal file
182
src/__tests__/data/achievements.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ACHIEVEMENTS } from '../../shared/data/achievements'
|
||||
|
||||
describe('achievements data', () => {
|
||||
describe('ACHIEVEMENTS structure', () => {
|
||||
it('should have exactly 8 achievements', () => {
|
||||
expect(ACHIEVEMENTS).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('should have all required properties', () => {
|
||||
ACHIEVEMENTS.forEach(achievement => {
|
||||
expect(achievement.id).toBeDefined()
|
||||
expect(achievement.title).toBeDefined()
|
||||
expect(achievement.description).toBeDefined()
|
||||
expect(achievement.icon).toBeDefined()
|
||||
expect(achievement.requirement).toBeDefined()
|
||||
expect(achievement.type).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have unique achievement IDs', () => {
|
||||
const ids = ACHIEVEMENTS.map(a => a.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('should have unique achievement titles', () => {
|
||||
const titles = ACHIEVEMENTS.map(a => a.title)
|
||||
const uniqueTitles = new Set(titles)
|
||||
expect(uniqueTitles.size).toBe(titles.length)
|
||||
})
|
||||
|
||||
it('should have positive requirements', () => {
|
||||
ACHIEVEMENTS.forEach(achievement => {
|
||||
expect(achievement.requirement).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('achievement types', () => {
|
||||
it('should have valid achievement types', () => {
|
||||
const validTypes = ['workouts', 'streak', 'calories', 'minutes']
|
||||
ACHIEVEMENTS.forEach(achievement => {
|
||||
expect(validTypes).toContain(achievement.type)
|
||||
})
|
||||
})
|
||||
|
||||
it('should have workouts type achievements', () => {
|
||||
const workoutAchievements = ACHIEVEMENTS.filter(a => a.type === 'workouts')
|
||||
expect(workoutAchievements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have streak type achievements', () => {
|
||||
const streakAchievements = ACHIEVEMENTS.filter(a => a.type === 'streak')
|
||||
expect(streakAchievements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have calories type achievements', () => {
|
||||
const calorieAchievements = ACHIEVEMENTS.filter(a => a.type === 'calories')
|
||||
expect(calorieAchievements.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should have minutes type achievements', () => {
|
||||
const minutesAchievements = ACHIEVEMENTS.filter(a => a.type === 'minutes')
|
||||
expect(minutesAchievements.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('specific achievements', () => {
|
||||
it('should have First Burn achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'first-burn')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('First Burn')
|
||||
expect(achievement!.requirement).toBe(1)
|
||||
expect(achievement!.type).toBe('workouts')
|
||||
})
|
||||
|
||||
it('should have Week Warrior achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'week-warrior')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('Week Warrior')
|
||||
expect(achievement!.requirement).toBe(7)
|
||||
expect(achievement!.type).toBe('streak')
|
||||
})
|
||||
|
||||
it('should have Century Club achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'century-club')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('Century Club')
|
||||
expect(achievement!.requirement).toBe(100)
|
||||
expect(achievement!.type).toBe('calories')
|
||||
})
|
||||
|
||||
it('should have Iron Will achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'iron-will')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('Iron Will')
|
||||
expect(achievement!.requirement).toBe(10)
|
||||
expect(achievement!.type).toBe('workouts')
|
||||
})
|
||||
|
||||
it('should have Tabata Master achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'tabata-master')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('Tabata Master')
|
||||
expect(achievement!.requirement).toBe(50)
|
||||
expect(achievement!.type).toBe('workouts')
|
||||
})
|
||||
|
||||
it('should have Marathon Burner achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'marathon-burner')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('Marathon Burner')
|
||||
expect(achievement!.requirement).toBe(100)
|
||||
expect(achievement!.type).toBe('minutes')
|
||||
})
|
||||
|
||||
it('should have Unstoppable achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'unstoppable')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('Unstoppable')
|
||||
expect(achievement!.requirement).toBe(30)
|
||||
expect(achievement!.type).toBe('streak')
|
||||
})
|
||||
|
||||
it('should have Calorie Crusher achievement', () => {
|
||||
const achievement = ACHIEVEMENTS.find(a => a.id === 'calorie-crusher')
|
||||
expect(achievement).toBeDefined()
|
||||
expect(achievement!.title).toBe('Calorie Crusher')
|
||||
expect(achievement!.requirement).toBe(1000)
|
||||
expect(achievement!.type).toBe('calories')
|
||||
})
|
||||
})
|
||||
|
||||
describe('achievement progression', () => {
|
||||
it('should have increasing workout requirements', () => {
|
||||
const workoutAchievements = ACHIEVEMENTS
|
||||
.filter(a => a.type === 'workouts')
|
||||
.sort((a, b) => a.requirement - b.requirement)
|
||||
|
||||
for (let i = 1; i < workoutAchievements.length; i++) {
|
||||
expect(workoutAchievements[i].requirement).toBeGreaterThan(workoutAchievements[i-1].requirement)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have increasing streak requirements', () => {
|
||||
const streakAchievements = ACHIEVEMENTS
|
||||
.filter(a => a.type === 'streak')
|
||||
.sort((a, b) => a.requirement - b.requirement)
|
||||
|
||||
for (let i = 1; i < streakAchievements.length; i++) {
|
||||
expect(streakAchievements[i].requirement).toBeGreaterThan(streakAchievements[i-1].requirement)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have increasing calorie requirements', () => {
|
||||
const calorieAchievements = ACHIEVEMENTS
|
||||
.filter(a => a.type === 'calories')
|
||||
.sort((a, b) => a.requirement - b.requirement)
|
||||
|
||||
for (let i = 1; i < calorieAchievements.length; i++) {
|
||||
expect(calorieAchievements[i].requirement).toBeGreaterThan(calorieAchievements[i-1].requirement)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon types', () => {
|
||||
it('should have string icon names', () => {
|
||||
ACHIEVEMENTS.forEach(achievement => {
|
||||
expect(typeof achievement.icon).toBe('string')
|
||||
expect(achievement.icon.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should use SF Symbol-like names', () => {
|
||||
const expectedIcons = ['flame', 'calendar', 'trophy', 'star', 'time', 'rocket']
|
||||
ACHIEVEMENTS.forEach(achievement => {
|
||||
expect(expectedIcons).toContain(achievement.icon)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user