Compare commits

..

10 Commits

Author SHA1 Message Date
Millian Lamiaux
edcd857c70 feat(admin-web, functions): overhaul music library and add AI genre classification
Some checks failed
CI / TypeScript (push) Failing after 48s
CI / ESLint (push) Failing after 20s
CI / Tests (push) Failing after 33s
CI / Build Check (push) Has been skipped
CI / Admin Web Tests (push) Failing after 18s
CI / Deploy Edge Functions (push) Has been skipped
- admin-web: Added an "All Music" library view with search, genre, and status filters.
- admin-web: Converted Jobs view to use expandable cards instead of a split pane.
- admin-web: Added ability to delete individual tracks from a job.
- functions: Added new `youtube-classify` edge function to automatically categorize tracks using Gemini LLM.
- functions: Integrated AI genre classification during initial playlist import if no manual genre is provided.
- worker: Added `/classify` endpoint for the worker to securely interface with Gemini.
- scripts: Updated deployment script to include `GEMINI_API_KEY`.
2026-03-29 12:52:02 +02:00
Millian Lamiaux
3d8d9efd70 feat: YouTube music download system with admin dashboard
Sidecar architecture: edge functions (auth + DB) → youtube-worker container
(youtubei.js for playlist metadata, yt-dlp for audio downloads).

- Edge functions: youtube-playlist, youtube-process, youtube-status (CRUD)
- youtube-worker sidecar: Node.js + yt-dlp, downloads M4A to Supabase Storage
- Admin music page: import playlists, process queue, inline genre editing
- 12 music genres with per-track assignment and import-time defaults
- DB migrations: download_jobs, download_items tables with genre column
- Deploy script and CI workflow for edge functions + sidecar
2026-03-26 10:47:05 +01:00
Millian Lamiaux
8926de58e5 refactor: extract player components, add stack headers, add tests
- Extract player UI into src/features/player/ components (TimerRing, BurnBar, etc.)
- Add transparent stack headers for workout/[id] and program/[id] screens
- Refactor workout/[id], program/[id], complete/[id] screens
- Add player feature tests and useTimer integration tests
- Add data layer exports and test setup improvements
2026-03-26 10:46:47 +01:00
Millian Lamiaux
569a9e178f fix: add missing getPopularWorkouts export to data layer
The workout complete screen imports getPopularWorkouts to show
recommended workouts, but the function was never implemented. Added it
to the data index — picks the first workout from each program week for
variety across categories and levels.
2026-03-26 00:06:46 +01:00
Millian Lamiaux
b833198e9d feat: migrate icons to SF Symbols, refactor explore tab, add collections/programs data layer
- Replace all Ionicons with native SF Symbols via expo-symbols SymbolView
- Create reusable Icon wrapper component (src/shared/components/Icon.tsx)
- Remove @expo/vector-icons and lucide-react dependencies
- Refactor explore tab with filters, search, and category browsing
- Add collections and programs data with Supabase integration
- Add explore filter store and filter sheet
- Update i18n strings (en, de, es, fr) for new explore features
- Update test mocks and remove stale snapshots
- Add user fitness level to user store and types
2026-03-25 23:28:51 +01:00
Millian Lamiaux
f11eb6b9ae fix: add missing Workout fields to program workouts and guard against undefined level
Program workouts built by buildProgramWorkouts() were missing level,
rounds, calories, and other Workout-interface fields, causing
workout.level.toLowerCase() to crash on the detail, collection, and
category screens. Added derived defaults (level from week number,
category from program id, standard Tabata timings) and defensive
fallbacks with ?? 'Beginner' at all call sites. Also fixed a potential
division-by-zero when exercises array is empty.
2026-03-25 23:28:47 +01:00
Millian Lamiaux
4fa8be600c test: add QA coverage — access unit tests, VideoPlayer snapshots, Maestro E2E flows, testIDs
- Add testIDs to explore, workout detail, and collection detail screens
- Add testID prop to VideoPlayer component
- Create access service unit tests (isFreeWorkout, canAccessWorkout)
- Create VideoPlayer rendering snapshot tests (preview/background modes)
- Create Maestro E2E flows: explore-freemium, collection-detail
- Update tab-navigation flow with Explore screen assertions
- Update profile-settings flow with real activity stat assertions
- Update all-tests suite to include new flows
2026-03-24 12:40:02 +01:00
Millian Lamiaux
a042c348c1 feat: close v1 feature gaps — freemium gating, video/audio infrastructure, EAS build config
- Add access control service with 3 free workouts (IDs 1, 11, 43), paywall gating on workout detail and lock indicators on explore grid
- Wire VideoPlayer into player background and workout detail preview
- Add placeholder HLS video URLs to 5 sample workouts (Apple test streams)
- Add test audio URLs to music service MOCK_TRACKS for all vibes
- Switch RevenueCat API key to env-based with sandbox fallback
- Create eas.json with development/preview/production build profiles
- Update app.json with iOS privacy descriptions (HealthKit, Camera) and non-exempt encryption flag
- Create collection detail screen (route crash fix)
- Replace hardcoded profile stats with real activity store data
- Add unlockWithPremium i18n key in EN/FR/DE/ES
2026-03-24 12:20:56 +01:00
Millian Lamiaux
cd065d07c3 feat: explore tab, React Query data layer, programs, sync, analytics, testing infrastructure
- Replace browse tab with Supabase-connected explore tab with filters
- Add React Query for data fetching with loading states
- Add 3 structured programs with weekly progression
- Add Supabase anonymous auth sync service
- Add PostHog analytics with screen tracking and events
- Add comprehensive test strategy (Vitest + Maestro E2E)
- Add RevenueCat subscription system with DEV simulation
- Add i18n translations for new screens (EN/FR/DE/ES)
- Add data deletion modal, sync consent modal
- Add assessment screen and program routes
- Add GitHub Actions CI workflow
- Update activity store with sync integration
2026-03-24 12:04:48 +01:00
Millian Lamiaux
8703c484e8 Replace workouts tab with explore tab connected to Supabase
- Rename workouts.tsx to explore.tsx with new functionality
- Add horizontal scrolling collections section with gradient cards
- Add featured workouts section
- Implement filtering by category (All, Full Body, Upper Body, Lower Body, Core, Cardio)
- Implement filtering by level (All Levels, Beginner, Intermediate, Advanced)
- Implement filtering by equipment (All, No Equipment, Band, Dumbbells, Mat)
- Add clear filters button when filters are active
- Add loading states with ActivityIndicator
- Add empty state for no results
- Update tab label from "Workouts" to "Explore"
- Add explore translations for en, fr, de, es
2026-03-23 21:27:19 +01:00
203 changed files with 35855 additions and 2640 deletions

View 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>
);
}
```

View 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

View 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

View 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.

View 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.

View 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)

View 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;
}
```

View 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>
);
}
```

View 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} />;
}
```

View 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",
]);
```

View 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>
);
}
```

View File

@@ -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.

View 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

View 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>
);
}
```

View 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

View 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

View 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.

View 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);
}

View 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"
}
}

View 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);
}

View 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
```

View 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

View 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

View 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

View 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
```

View 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

View 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
```

View 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 };
```

View 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.

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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"

View 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

View 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"

View 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"

View 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"

View 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"

View 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

View File

@@ -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 |

View File

@@ -5,6 +5,8 @@
![Expo](https://img.shields.io/badge/Expo-52-black)
![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue)
![License](https://img.shields.io/badge/License-Proprietary-red)
![Tests](https://img.shields.io/badge/Tests-546%20passing-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-Statements%20%7C%20Branches%20%7C%20Functions%20%7C%20Lines-blue)
## 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

File diff suppressed because it is too large Load Diff

View File

@@ -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() {

View 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 })
})
})

View 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()
}
})
})

View 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()
}
})
})

View File

@@ -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
}
}
}
}
}

View 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,
};
}

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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>

View File

@@ -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,
},
})
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,
},
})
}

View File

@@ -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],
},
})
}

View File

@@ -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,
},
})
}

View File

@@ -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
View 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],
},
})
}

View File

@@ -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>

View File

@@ -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],
},
})
}

View File

@@ -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
View 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>
)
}

View File

@@ -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,
},

View File

@@ -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

View File

@@ -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
View 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,
},
})

View File

@@ -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,
},
})

View File

@@ -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>

View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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
View 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"
}
}
}

View 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')
})
})
})

View 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)
})
})
})

View 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)
})
})
})

View 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()
})
})

View 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!()
})
})
})

View 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()
})
})

View 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)
})
})

View 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)
})
})

View 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!()
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View File

@@ -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>
`;

View 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