create-proto 0.7.11 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +2 -2
- package/template/AGENTS.md +62 -135
- package/template/components/proto/Alert.tsx +79 -0
- package/template/components/proto/Button.tsx +66 -40
- package/template/components/proto/DatePicker.tsx +52 -0
- package/template/components/proto/Menu.tsx +62 -0
- package/template/components/proto/Modal.tsx +37 -15
- package/template/components/proto/Picker.tsx +57 -0
- package/template/components/proto/Row.tsx +5 -2
- package/template/components/proto/Screen.tsx +20 -2
- package/template/components/proto/index.ts +5 -1
- package/template/components/proto/touch-dots.tsx +256 -8
- package/template/gitignore +1 -0
- package/template/package.json +30 -29
- package/template/pnpm-workspace.yaml +2 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ No canvas. No IDE. No engineering concepts.
|
|
|
17
17
|
## What this command does
|
|
18
18
|
|
|
19
19
|
1. Scaffolds the project: `DESIGN.md` (design system source-of-truth), `CLAUDE.md` (Claude Code instructions for Prototo-aware generation), Prototo component library, a starter Welcome screen
|
|
20
|
-
2. Installs dependencies (Expo SDK
|
|
20
|
+
2. Installs dependencies (Expo SDK 57, React Native, Reanimated 4, `@expo/ui`, `expo-glass-effect`, `expo-clipboard`)
|
|
21
21
|
3. Auto-launches Metro and opens the iOS Simulator
|
|
22
22
|
|
|
23
23
|
Then in another terminal:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-proto",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Scaffold a new Proto prototype: `npm create proto@latest myapp`. Describe a screen, watch your prototype run natively on iPhone — designer-first, paired with Claude Code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"fs-extra": "^11.2.0",
|
|
46
46
|
"qrcode-terminal": "^0.12.0",
|
|
47
47
|
"validate-npm-package-name": "^5.0.1",
|
|
48
|
-
"@sherizan/proto-cli": "^0.
|
|
48
|
+
"@sherizan/proto-cli": "^0.8.17"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/fs-extra": "^11.0.4",
|
package/template/AGENTS.md
CHANGED
|
@@ -1,55 +1,51 @@
|
|
|
1
1
|
# Prototo Project — Agent Instructions
|
|
2
2
|
|
|
3
|
-
You're the design tool inside a Prototo project. The designer prompts
|
|
3
|
+
You're the design tool inside a Prototo project. The designer prompts in plain language; you build native iOS screens. The iOS Simulator is the canvas. Designers never touch files: they prompt, you write.
|
|
4
4
|
|
|
5
5
|
## Read first
|
|
6
|
-
- `DESIGN.md` —
|
|
7
|
-
- `/screens/` — what already exists
|
|
6
|
+
- `DESIGN.md` — tokens and the project's decisions. Keep it alive: update it when colors, type, spacing, shape or accent change, and add a one-line entry to its Screens section for every new screen.
|
|
7
|
+
- `/screens/` — what already exists.
|
|
8
8
|
|
|
9
9
|
## Building blocks (use whatever fits)
|
|
10
10
|
|
|
11
|
-
**Native iOS**
|
|
11
|
+
**Native iOS first** (Apple handles Liquid Glass, SF Symbols, accessibility, Dynamic Type):
|
|
12
|
+
- `expo-router/unstable-native-tabs` — native `UITabBar` (shape below). Never build a custom tab bar.
|
|
13
|
+
- `expo-router` `Stack` with `headerLargeTitle: true` + a per-route `title`. Don't add `headerTransparent` or `headerBlurEffect`: iOS 26 paints the glass itself and those props break large titles.
|
|
14
|
+
- `expo-symbols` `SymbolView` for SF Symbols (private-use codepoints in `Text` don't render).
|
|
15
|
+
- `@expo/ui/swift-ui` (`Button`, `Toggle`, `Form`, `Section`…) · `expo-glass-effect` `GlassView`.
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
- `expo-router` `Stack` with `headerLargeTitle: true` + `title` set per route — native large-title nav bar. Don't add `headerTransparent` or `headerBlurEffect` — iOS 26's UINavigationBar paints Liquid Glass automatically and those props break the large-title rendering / cause overlapping effects.
|
|
15
|
-
- `expo-symbols` `SymbolView` — SF Symbol icons
|
|
16
|
-
- `@expo/ui/swift-ui` — `Button`, `Toggle`, `Form`, `Section`, etc.
|
|
17
|
-
- `expo-glass-effect` `GlassView` — Liquid Glass surfaces
|
|
18
|
-
|
|
19
|
-
**Prototo primitives** in `/components/proto` — small set of themed fallbacks. Props cheat sheet (source is read-only; only read a file if something here doesn't match):
|
|
17
|
+
**Prototo primitives** from `../components/proto` (read-only; open a source file only if this table doesn't match):
|
|
20
18
|
|
|
21
19
|
| Primitive | Props |
|
|
22
20
|
|---|---|
|
|
23
|
-
| `Screen` | `scrollable
|
|
24
|
-
| `Stack` | `gap?`, `padding?`, `align?: 'start'\|'center'\|'end'` (unset =
|
|
25
|
-
| `Row` | `gap?`, `align
|
|
21
|
+
| `Screen` | `scrollable?` (default true), `gradient?` (true = light accent wash rising from the bottom, or a colour) |
|
|
22
|
+
| `Stack` | `gap?`, `padding?`, `align?: 'start'\|'center'\|'end'` (unset = stretch), `style?` |
|
|
23
|
+
| `Row` | `gap?`, `align?` (default 'start'), `style?` |
|
|
26
24
|
| `Text` | `size?: 'title'\|'headline'\|'body'\|'caption'\|'label'`, `color?: 'primary'\|'secondary'\|'accent'\|'destructive'`, `style?` |
|
|
27
|
-
| `Card` | `glass
|
|
28
|
-
| `Button` | `label
|
|
29
|
-
| `Toggle` | `label
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
25
|
+
| `Card` | `glass?` (iOS 26 material, plain View on older iOS), `padding?` |
|
|
26
|
+
| `Button` | native Liquid Glass. `label`, `variant?: 'primary'\|'secondary'\|'ghost'\|'destructive'`, `onPress?`, `disabled?`, `systemImage?` (SF Symbol name), `image?` (picture URL for a brand mark) |
|
|
27
|
+
| `Toggle` / `Slider` / `Stepper` | native SwiftUI, accent-tinted. `Toggle`: `label`, `value`, `onChange?` · `Slider`: `value`, `onChange?`, `min?`, `max?`, `step?`, `label?` · `Stepper`: `label`, `value`, `onChange`, `min?`, `max?`, `step?` |
|
|
28
|
+
| `Divider` | `label?` |
|
|
29
|
+
| `Input` | RN `TextInputProps` |
|
|
30
|
+
| `Modal` | native bottom sheet, sizes to its content. `title`, `visible`, `onClose?` |
|
|
31
|
+
| `Picker` | native segmented control. `options: string[]`, `value`, `onChange?`, `label?`, `variant?: 'segmented'\|'menu'\|'wheel'` |
|
|
32
|
+
| `DatePicker` | native compact picker. `value: Date`, `onChange?`, `label?`, `mode?: 'date'\|'time'\|'dateTime'`, `min?`, `max?` |
|
|
33
|
+
| `Menu` | native pull-down. `label`, `systemImage?`, `items: { label, onPress?, destructive?, systemImage? }[]` |
|
|
34
|
+
| `Alert` | native alert, controlled. `title`, `message?`, `visible`, `onClose?`, `actions: { label, onPress?, destructive?, cancel? }[]` |
|
|
35
35
|
| `Lottie` | `source`, `autoPlay?`, `loop?`, `style?` |
|
|
36
|
-
`Toggle`, `Slider`, and `Stepper` render real native SwiftUI (`@expo/ui`) on iOS, tinted with the app accent. Card's `glass={true}` uses `expo-glass-effect`'s native iOS 26 material; on older iOS it falls back to a plain View.
|
|
37
|
-
|
|
38
|
-
**Prototo motion + graphics** — four subpath modules in `/components/proto` cover animation and drawing. Pick by what the prompt actually asks for:
|
|
39
|
-
|
|
40
|
-
- `../components/proto/motion` — `Motion.View` + `Motion.Pressable`. **Default for transitions.** Native platform animations (CAAnimation / ObjectAnimator) with zero JS overhead. Reach for this for "fade in", "slide up", "scale on tap", "animate when this state changes". Driven by `react-native-ease`.
|
|
41
|
-
- `../components/proto/gestures` — `AnimatedView`, `useSharedValue`, `useAnimatedStyle`, `withSpring`, `Gesture`, `GestureDetector`, etc. Use **only** when the animation must read gesture state, scroll position, or interpolate continuously: "drag this card", "swipe to delete", "parallax this header". Driven by `react-native-reanimated` + `react-native-gesture-handler`.
|
|
42
|
-
- `../components/proto/lottie` — `Lottie` component. Plays `.json` files dropped into `/assets/lottie/`. The designer brings the animation file (LottieFiles / After Effects export); you wire it: `<Lottie source={require('../assets/lottie/<name>.json')} />`. Defaults to `autoPlay` and `loop`. Driven by `lottie-react-native`.
|
|
43
|
-
- `../components/proto/canvas` — `Canvas`, `Path`, `Circle`, `Rect`, `LinearGradient`, etc. For custom drawing that doesn't fit RN's box model: confetti bursts, custom charts, badge shapes. Driven by `@shopify/react-native-skia`.
|
|
44
|
-
- `../components/proto/svg` — `Svg`, `Path`, `Circle`, `Rect`, `G`, `LinearGradient`, etc. For vector icons, logos, and illustrations. You can also import an SVG file directly: `import Logo from '../assets/logo.svg'` then `<Logo width={120} height={40} />`. Driven by `react-native-svg`. Use this for static vector art; reach for `canvas` only when you need to animate or compute the drawing.
|
|
45
36
|
|
|
46
|
-
|
|
37
|
+
**Motion + graphics** — always import through these subpaths, never the underlying library:
|
|
38
|
+
- `../components/proto/motion` — `Motion.View`, `Motion.Pressable`. **Default** for fades, slides, scale on tap, state-change animations.
|
|
39
|
+
- `../components/proto/gestures` — `AnimatedView`, `useSharedValue`, `useAnimatedStyle`, `withSpring`, `Gesture`, `GestureDetector`. Only when animation follows a drag, swipe, scroll or continuous value.
|
|
40
|
+
- `../components/proto/lottie` — plays designer-supplied files: `<Lottie source={require('../assets/lottie/<name>.json')} />`.
|
|
41
|
+
- `../components/proto/svg` — static vector art; `import Logo from '../assets/logo.svg'` works too.
|
|
42
|
+
- `../components/proto/canvas` — computed or animated drawing (confetti, charts, custom shapes).
|
|
47
43
|
|
|
48
|
-
**
|
|
44
|
+
**Sensors** — `expo-sensors` (`Accelerometer`, `Gyroscope`, `DeviceMotion`, `Magnetometer`, `Barometer`, `Pedometer`). Guard with `isAvailableAsync()` and render a still fallback: the Simulator never fires motion, only a real iPhone does (via a published link in the Prototo app). Call `requestPermissionsAsync()` before `DeviceMotion` or `Pedometer`.
|
|
49
45
|
|
|
50
|
-
|
|
46
|
+
**Custom** — when nothing fits, write it with React Native in `/components/shared/`. The designer's vision wins.
|
|
51
47
|
|
|
52
|
-
|
|
48
|
+
**Adding a package** — `npx proto add <package>` only (never `npm install` / `pnpm add`; bare `proto` isn't on PATH). It says when a package needs native code Prototo doesn't bundle.
|
|
53
49
|
|
|
54
50
|
## File layout
|
|
55
51
|
|
|
@@ -64,9 +60,12 @@ To add any npm package (a font, an icon set, a utility), run `npx proto add <pac
|
|
|
64
60
|
|
|
65
61
|
A new screen `screens/Settings.tsx` needs:
|
|
66
62
|
- `app/settings.tsx` re-exporting it (`import Settings from '../screens/Settings'; export default function SettingsRoute() { return <Settings />; }`)
|
|
67
|
-
- A title
|
|
63
|
+
- A title in `app/_layout.tsx`: `<Stack.Screen name="settings" options={{ title: 'Settings' }} />`
|
|
64
|
+
- Route filenames are lowercase kebab-case.
|
|
65
|
+
|
|
66
|
+
**Linking screens** — Prototo draws the prototype's flow (Export flow) from the code, so write navigation where it can read it: `router.push('/settings')` inline in the `onPress`, or `<Link href="/settings">`, in the screen file itself (or in that screen's `<Stack.Screen options>` for a header button). A handler defined elsewhere, a link inside a shared component, or a `[param]` route still works, but that arrow won't start at its button.
|
|
68
67
|
|
|
69
|
-
**
|
|
68
|
+
**NativeTabs** — exact shape for this project's `expo-router` (`Icon`/`Label` are nested under `Trigger`, not flat imports):
|
|
70
69
|
|
|
71
70
|
```tsx
|
|
72
71
|
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
|
@@ -78,106 +77,34 @@ export default function Layout() {
|
|
|
78
77
|
<NativeTabs.Trigger.Icon sf="house.fill" />
|
|
79
78
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
|
80
79
|
</NativeTabs.Trigger>
|
|
81
|
-
<NativeTabs.Trigger name="profile">
|
|
82
|
-
<NativeTabs.Trigger.Icon sf="person.fill" />
|
|
83
|
-
<NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label>
|
|
84
|
-
</NativeTabs.Trigger>
|
|
85
80
|
</NativeTabs>
|
|
86
81
|
);
|
|
87
82
|
}
|
|
88
83
|
```
|
|
89
84
|
|
|
90
|
-
|
|
91
|
-
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
##
|
|
104
|
-
|
|
105
|
-
-
|
|
106
|
-
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Do this especially after layout, color, typography, or spacing changes. If `proto shot` reports no preview is running, the designer needs to run `proto start` first.
|
|
118
|
-
|
|
119
|
-
## Proto MCP tools
|
|
120
|
-
|
|
121
|
-
When the designer runs `proto start`, a local MCP server (`prototo`) connects automatically — no setup. It gives you four tools that close the feedback loop, so you can see what you built instead of asking the designer to relay it. (Under Codex, the tools appear once the designer trusts the project — Codex asks on first open. If the prototo tools are missing, say so and ask the designer to relaunch Codex and trust the project; meanwhile `proto shot` still works.)
|
|
122
|
-
|
|
123
|
-
**At the start of any fix session** (the designer says something is broken, red, or not working):
|
|
124
|
-
|
|
125
|
-
1. Call `get_metro_errors` first — it returns the current build failures and runtime crashes from the running prototype, with the raw error text and the screen involved. Don't ask the designer to paste or describe an error before checking it.
|
|
126
|
-
|
|
127
|
-
**After every screen write:**
|
|
128
|
-
|
|
129
|
-
1. Call `compile_check` with the screen name — it type-checks the project and reports any problems in plain language. Fix anything it surfaces before moving on.
|
|
130
|
-
2. Call `get_simulator_screenshot` — it returns what the prototype actually renders right now. Inspect it for the same defects as above.
|
|
131
|
-
|
|
132
|
-
**After editing `app/_layout.tsx` or any navigator:** call `reload_app` — root-layout changes don't Fast-Refresh, and a stale screenshot looks plausible.
|
|
133
|
-
|
|
134
|
-
Never assume a screen rendered correctly — check the screenshot. Never ask the designer to describe an error you can catch with `get_metro_errors` or `compile_check`. If a tool says the Simulator isn't running (or Metro reports clean while the designer still sees an error), the designer needs to run `proto start` first.
|
|
135
|
-
|
|
136
|
-
(`get_simulator_screenshot` is the automated form of the `proto shot` loop above — prefer the tool when it's available.)
|
|
137
|
-
|
|
138
|
-
## Mock vs real data
|
|
139
|
-
|
|
140
|
-
When a screen shows placeholder numbers that aren't yet wired to a real source, wrap them in `mock()` from `../components/proto` — `const conditions = mock({ wave: '0.8m' })`. It returns the value unchanged, so nothing breaks; it just makes stubbed data obvious and greppable so fake numbers never ship believing they're real. When you wire the value to a live source (a `fetch`), drop the `mock()` wrapper. (Don't use code comments to mark mock data — generated screens stay comment-free.)
|
|
141
|
-
|
|
142
|
-
### Making it real
|
|
143
|
-
|
|
144
|
-
When the designer says "use real data", follow this shape so every screen handles loading and failure the same way:
|
|
145
|
-
|
|
146
|
-
1. Keep the `mock()` value as the **starting state** — it's what shows before the fetch resolves and if the network fails. Drop the `mock()` wrapper from the live value once wired.
|
|
147
|
-
2. Fetch in an effect, guarding against the screen unmounting:
|
|
148
|
-
|
|
149
|
-
```tsx
|
|
150
|
-
const [data, setData] = useState(FALLBACK);
|
|
151
|
-
const [loading, setLoading] = useState(true);
|
|
152
|
-
useEffect(() => {
|
|
153
|
-
let alive = true;
|
|
154
|
-
fetch(URL)
|
|
155
|
-
.then((r) => r.json())
|
|
156
|
-
.then((json) => { if (alive) setData(shape(json)); })
|
|
157
|
-
.catch(() => {})
|
|
158
|
-
.finally(() => { if (alive) setLoading(false); });
|
|
159
|
-
return () => { alive = false; };
|
|
160
|
-
}, []);
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
3. While `loading`, show a skeleton or the fallback — never a blank screen. On error, keep the fallback (the `.catch` above already does this); don't surface a raw error to the designer.
|
|
164
|
-
4. Put fetch + shaping logic in `/components/shared/<name>Data.ts`, not inline in the screen.
|
|
165
|
-
|
|
166
|
-
**Keyless APIs** (no key, CORS-open, good for prototypes): Open-Meteo (weather/marine/air), REST Countries, Open Library, PokéAPI, Art Institute of Chicago, TheMealDB, Wikipedia REST. Prefer these so "make it real" stays a one-prompt step. If a source needs a key, tell the designer that key goes in `proto.config.js`, nowhere else.
|
|
167
|
-
|
|
168
|
-
## Sharing your prototype
|
|
169
|
-
|
|
170
|
-
Run `proto share` to publish the prototype and get a permanent `prototo.app/p/<token>` link. Recipients open it on iPhone with the free **Prototo** app (the link page walks them through installing it) and the prototype runs **natively on their device** — real gestures, haptics, and Liquid Glass, with `motion`/`gestures`/`canvas`/`svg`/`lottie`, live data, and custom logic exactly as they run on the designer's Simulator. There's nothing to dumb down — build whatever the designer asks for and it all shares.
|
|
171
|
-
|
|
172
|
-
Two limits worth knowing: the prototype must be built on the **current Prototo** (opening an older share tells the recipient to ask the designer to publish it again — re-scaffold if prompted), and it can only use native modules Prototo bundles — `npx proto add` tells you when a package needs native code that isn't available.
|
|
173
|
-
|
|
174
|
-
## When modifying
|
|
175
|
-
|
|
176
|
-
Read the file first, then make precise, targeted edits to the parts that change. Keep edits scoped — don't rewrite a whole file when a few lines change.
|
|
177
|
-
|
|
178
|
-
## Avoid
|
|
179
|
-
|
|
180
|
-
- Custom tab bars — `expo-router/unstable-native-tabs` is strictly better (real Liquid Glass, real SF Symbols, system blur).
|
|
181
|
-
- SF Symbol private-use Unicode codepoints (`''`, `''`) in plain Text — they don't render. Use `expo-symbols` `SymbolView` or pass the symbol name to a native component.
|
|
182
|
-
- Editing `/components/proto/`, `.proto/`, `app.config.js`, `babel.config.js`, `metro.config.js`.
|
|
183
|
-
- Telling the designer to open or edit a file manually. They prompt; you write.
|
|
85
|
+
## Theme, dark mode, accessibility
|
|
86
|
+
- Colors come from `useTheme()` (`theme.surface.*`, `theme.text.*`, `theme.border.*`): light/dark is automatic, so no hardcoded hex. `colorScheme: 'light' | 'dark'` in `proto.config.js` pins a scheme.
|
|
87
|
+
- Custom brand colors, fonts or constants live **once** in `/components/shared/theme.ts` (define light + dark variants if needed). Never paste a palette into more than one screen; lift any you find.
|
|
88
|
+
- Text scales with Dynamic Type: never disable it, avoid fixed heights on text containers.
|
|
89
|
+
- `a11y` from `../components/proto`: tap targets ≥ `a11y.minTapTarget` (44pt), contrast ≥ `a11y.minTextContrast`, in light and dark.
|
|
90
|
+
|
|
91
|
+
## Check your work (Proto MCP tools)
|
|
92
|
+
The `prototo` MCP server connects while the preview runs. (Under Codex it appears once the designer trusts the project; if it's missing, ask them to relaunch Codex and trust it.)
|
|
93
|
+
- **Something's broken / red?** Call `get_metro_errors` first. Never ask the designer to paste an error.
|
|
94
|
+
- **After every screen write:** `compile_check` with the screen name, fix what it reports, then `get_simulator_screenshot` and inspect for overlap, clipping, low contrast, cramped or uneven spacing, wrong colors. Iterate until it looks right; don't make the designer your QA.
|
|
95
|
+
- **After editing `app/_layout.tsx` or any navigator:** `reload_app` first. Root layouts don't Fast-Refresh and a stale screenshot looks plausible.
|
|
96
|
+
- No MCP? `npx proto shot` writes `.proto/last-shot.png`; read it. If the tools say nothing is running, ask the designer to restart the preview.
|
|
97
|
+
|
|
98
|
+
## Data
|
|
99
|
+
- Placeholder values: wrap in `mock()` from `../components/proto` (`mock({ wave: '0.8m' })`). It returns the value unchanged and marks it as fake. No code comments for this.
|
|
100
|
+
- "Use real data": keep the mock value as the starting state and fallback, fetch in an effect with an `alive` guard, show a skeleton or the fallback while loading, keep the fallback on error (never surface a raw error). Fetch + shaping logic goes in `/components/shared/<name>Data.ts`.
|
|
101
|
+
- Keyless APIs: Open-Meteo, REST Countries, Open Library, PokéAPI, Art Institute of Chicago, TheMealDB, Wikipedia REST. A key goes in `proto.config.js`, nowhere else.
|
|
102
|
+
|
|
103
|
+
## Sharing
|
|
104
|
+
A published link (`prototo.app/p/<token>`) runs the prototype natively in the free Prototo iPhone app, with everything above: gestures, haptics, glass, motion, live data. Build what the designer asks; nothing needs dumbing down. Limits: only native modules Prototo bundles (`npx proto add` tells you), and the project must be on the current Prototo runtime. If a command says it's older, run `npx proto upgrade` (never Expo or npm commands for this), then publish again so the existing link opens on the new app.
|
|
105
|
+
|
|
106
|
+
## Rules
|
|
107
|
+
- Read a file, then make targeted edits; don't rewrite whole files for small changes.
|
|
108
|
+
- Generated screens have no code comments.
|
|
109
|
+
- Never edit `/components/proto/`, `.proto/`, `app.config.js`, `babel.config.js`, `metro.config.js`.
|
|
110
|
+
- Never tell the designer to open or edit a file.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { Alert as RNAlert, Platform } from 'react-native';
|
|
3
|
+
import { Alert as SwiftUIAlert, Button as SwiftUIButton, Host, Text as SwiftUIText } from '@expo/ui/swift-ui';
|
|
4
|
+
import { frame, opacity } from '@expo/ui/swift-ui/modifiers';
|
|
5
|
+
|
|
6
|
+
export type AlertAction = {
|
|
7
|
+
label: string;
|
|
8
|
+
onPress?: () => void;
|
|
9
|
+
destructive?: boolean;
|
|
10
|
+
cancel?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AlertProps = {
|
|
14
|
+
title: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
visible: boolean;
|
|
17
|
+
onClose?: () => void;
|
|
18
|
+
actions: AlertAction[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const roleOf = (action: AlertAction) =>
|
|
22
|
+
action.destructive ? 'destructive' : action.cancel ? 'cancel' : undefined;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Apple's own alert. Controlled by `visible`; onClose fires after any action or dismiss.
|
|
26
|
+
*/
|
|
27
|
+
export function Alert({ title, message, visible, onClose, actions }: AlertProps) {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (Platform.OS === 'ios' || !visible) return;
|
|
30
|
+
// ponytail: iOS-first product; Android uses the system alert, which closes itself.
|
|
31
|
+
RNAlert.alert(
|
|
32
|
+
title,
|
|
33
|
+
message,
|
|
34
|
+
actions.map((action) => ({
|
|
35
|
+
text: action.label,
|
|
36
|
+
style: roleOf(action) ?? 'default',
|
|
37
|
+
onPress: () => {
|
|
38
|
+
action.onPress?.();
|
|
39
|
+
onClose?.();
|
|
40
|
+
},
|
|
41
|
+
})),
|
|
42
|
+
{ onDismiss: onClose },
|
|
43
|
+
);
|
|
44
|
+
}, [visible]);
|
|
45
|
+
|
|
46
|
+
if (Platform.OS !== 'ios') return null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Host matchContents>
|
|
50
|
+
<SwiftUIAlert
|
|
51
|
+
title={title}
|
|
52
|
+
isPresented={visible}
|
|
53
|
+
onIsPresentedChange={(open) => {
|
|
54
|
+
if (!open) onClose?.();
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{/* ponytail: SwiftUI hangs the alert off a trigger view; an invisible one keeps it controlled by `visible`. */}
|
|
58
|
+
<SwiftUIAlert.Trigger>
|
|
59
|
+
<SwiftUIText modifiers={[frame({ width: 0, height: 0 }), opacity(0)]}> </SwiftUIText>
|
|
60
|
+
</SwiftUIAlert.Trigger>
|
|
61
|
+
{message ? (
|
|
62
|
+
<SwiftUIAlert.Message>
|
|
63
|
+
<SwiftUIText>{message}</SwiftUIText>
|
|
64
|
+
</SwiftUIAlert.Message>
|
|
65
|
+
) : null}
|
|
66
|
+
<SwiftUIAlert.Actions>
|
|
67
|
+
{actions.map((action) => (
|
|
68
|
+
<SwiftUIButton
|
|
69
|
+
key={action.label}
|
|
70
|
+
label={action.label}
|
|
71
|
+
role={roleOf(action)}
|
|
72
|
+
onPress={action.onPress}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
</SwiftUIAlert.Actions>
|
|
76
|
+
</SwiftUIAlert>
|
|
77
|
+
</Host>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -1,52 +1,85 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type
|
|
3
|
-
import
|
|
1
|
+
import { Platform, Pressable, type ViewStyle } from 'react-native';
|
|
2
|
+
import { useContext, type ComponentProps } from 'react';
|
|
3
|
+
import { Host, Button as SwiftUIButton, Image as SwiftUIImage, Label as SwiftUILabel } from '@expo/ui/swift-ui';
|
|
4
|
+
import {
|
|
5
|
+
buttonStyle,
|
|
6
|
+
controlSize,
|
|
7
|
+
disabled as disabledModifier,
|
|
8
|
+
frame,
|
|
9
|
+
resizable,
|
|
10
|
+
tint,
|
|
11
|
+
} from '@expo/ui/swift-ui/modifiers';
|
|
4
12
|
import * as Haptics from 'expo-haptics';
|
|
5
13
|
import { useTheme, useAccent } from './useTheme';
|
|
6
14
|
import { Text } from './Text';
|
|
15
|
+
import { RowContext } from './Row';
|
|
7
16
|
|
|
8
17
|
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
|
|
18
|
+
export type SFSymbol = NonNullable<ComponentProps<typeof SwiftUIButton>['systemImage']>;
|
|
9
19
|
|
|
10
20
|
export type ButtonProps = {
|
|
11
21
|
label: string;
|
|
12
22
|
variant?: ButtonVariant;
|
|
13
23
|
onPress?: () => void;
|
|
14
24
|
disabled?: boolean;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
textStyle?: TextStyle;
|
|
25
|
+
systemImage?: SFSymbol;
|
|
26
|
+
image?: string;
|
|
18
27
|
};
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const STYLE = {
|
|
30
|
+
primary: 'glassProminent',
|
|
31
|
+
secondary: 'glass',
|
|
32
|
+
ghost: 'borderless',
|
|
33
|
+
destructive: 'glassProminent',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apple's own button: Liquid Glass on iOS 26 (SwiftUI `buttonStyle`), tinted with the
|
|
38
|
+
* prototype's accent. `systemImage` is an SF Symbol name shown before the label; `image` is a
|
|
39
|
+
* picture URL (https or data) for a brand mark instead.
|
|
40
|
+
*/
|
|
41
|
+
export function Button({ label, variant = 'primary', onPress, disabled = false, systemImage, image }: ButtonProps) {
|
|
29
42
|
const theme = useTheme();
|
|
30
43
|
const accent = useAccent();
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const animated = useAnimatedStyle(() => ({
|
|
34
|
-
transform: [{ scale: scale.value }],
|
|
35
|
-
}));
|
|
44
|
+
const inRow = useContext(RowContext);
|
|
36
45
|
|
|
37
|
-
const handlePressIn = () => {
|
|
38
|
-
if (disabled) return;
|
|
39
|
-
scale.value = withTiming(0.96, { duration: 80 });
|
|
40
|
-
};
|
|
41
|
-
const handlePressOut = () => {
|
|
42
|
-
if (disabled) return;
|
|
43
|
-
scale.value = withTiming(1, { duration: 120 });
|
|
44
|
-
};
|
|
45
46
|
const handlePress = () => {
|
|
46
47
|
Haptics.selectionAsync().catch(() => {});
|
|
47
48
|
onPress?.();
|
|
48
49
|
};
|
|
49
50
|
|
|
51
|
+
if (Platform.OS === 'ios') {
|
|
52
|
+
return (
|
|
53
|
+
// ponytail: a fixed-height host, like Slider, fills a Stack; matchContents measures the
|
|
54
|
+
// button unconstrained (SwiftUI fixedSize) so inside a Row it hugs its label instead.
|
|
55
|
+
<Host
|
|
56
|
+
matchContents={inRow ? { horizontal: true } : undefined}
|
|
57
|
+
style={inRow ? { height: 50 } : { alignSelf: 'stretch', height: 50 }}
|
|
58
|
+
>
|
|
59
|
+
<SwiftUIButton
|
|
60
|
+
role={variant === 'destructive' ? 'destructive' : undefined}
|
|
61
|
+
onPress={handlePress}
|
|
62
|
+
modifiers={[
|
|
63
|
+
buttonStyle(STYLE[variant]),
|
|
64
|
+
controlSize('large'),
|
|
65
|
+
tint(variant === 'destructive' ? theme.text.destructive : accent),
|
|
66
|
+
disabledModifier(disabled),
|
|
67
|
+
]}
|
|
68
|
+
>
|
|
69
|
+
{/* ponytail: the frame goes on the label so the glass stretches with it; SwiftUI's
|
|
70
|
+
`.infinity` has no JSON form, a huge maxWidth stretches the same way. */}
|
|
71
|
+
<SwiftUILabel
|
|
72
|
+
title={label}
|
|
73
|
+
systemImage={systemImage}
|
|
74
|
+
icon={image ? <SwiftUIImage uiImage={image} modifiers={[resizable(), frame({ width: 18, height: 18 })]} /> : undefined}
|
|
75
|
+
modifiers={[frame({ maxWidth: 100000 })]}
|
|
76
|
+
/>
|
|
77
|
+
</SwiftUIButton>
|
|
78
|
+
</Host>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ponytail: iOS-first product; Android keeps a plain themed Pressable.
|
|
50
83
|
const palette: Record<ButtonVariant, { bg: string; fg: string }> = {
|
|
51
84
|
primary: { bg: accent, fg: '#FFFFFF' },
|
|
52
85
|
secondary: { bg: theme.surface.secondary, fg: theme.text.primary },
|
|
@@ -54,8 +87,7 @@ export function Button({
|
|
|
54
87
|
destructive: { bg: theme.text.destructive, fg: '#FFFFFF' },
|
|
55
88
|
};
|
|
56
89
|
const { bg, fg } = palette[variant];
|
|
57
|
-
|
|
58
|
-
const baseStyle: ViewStyle = {
|
|
90
|
+
const style: ViewStyle = {
|
|
59
91
|
backgroundColor: bg,
|
|
60
92
|
borderRadius: theme.radius.button,
|
|
61
93
|
paddingVertical: theme.space.sm + 4,
|
|
@@ -64,17 +96,11 @@ export function Button({
|
|
|
64
96
|
justifyContent: 'center',
|
|
65
97
|
opacity: disabled ? 0.5 : 1,
|
|
66
98
|
};
|
|
67
|
-
|
|
68
99
|
return (
|
|
69
|
-
<Pressable disabled={disabled}
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<Text size="label" style={[{ color: fg }, textStyle]}>
|
|
74
|
-
{label}
|
|
75
|
-
</Text>
|
|
76
|
-
</View>
|
|
77
|
-
</Animated.View>
|
|
100
|
+
<Pressable disabled={disabled} onPress={handlePress} style={style}>
|
|
101
|
+
<Text size="label" style={{ color: fg }}>
|
|
102
|
+
{label}
|
|
103
|
+
</Text>
|
|
78
104
|
</Pressable>
|
|
79
105
|
);
|
|
80
106
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import { Host, DatePicker as SwiftUIDatePicker } from '@expo/ui/swift-ui';
|
|
3
|
+
import { datePickerStyle, tint } from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
import { useAccent } from './useTheme';
|
|
5
|
+
import { Text } from './Text';
|
|
6
|
+
|
|
7
|
+
export type DatePickerMode = 'date' | 'time' | 'dateTime';
|
|
8
|
+
|
|
9
|
+
export type DatePickerProps = {
|
|
10
|
+
value: Date;
|
|
11
|
+
onChange?: (date: Date) => void;
|
|
12
|
+
label?: string;
|
|
13
|
+
mode?: DatePickerMode;
|
|
14
|
+
min?: Date;
|
|
15
|
+
max?: Date;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const COMPONENTS = {
|
|
19
|
+
date: ['date'],
|
|
20
|
+
time: ['hourAndMinute'],
|
|
21
|
+
dateTime: ['date', 'hourAndMinute'],
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Apple's own compact date picker (label left, tappable value right).
|
|
26
|
+
*/
|
|
27
|
+
export function DatePicker({ value, onChange, label, mode = 'date', min, max }: DatePickerProps) {
|
|
28
|
+
const accent = useAccent();
|
|
29
|
+
|
|
30
|
+
if (Platform.OS !== 'ios') {
|
|
31
|
+
// ponytail: iOS-first product; Android shows the value read-only.
|
|
32
|
+
return (
|
|
33
|
+
<Text size="body">
|
|
34
|
+
{label ? `${label}: ` : ''}
|
|
35
|
+
{value.toLocaleString()}
|
|
36
|
+
</Text>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Host style={{ alignSelf: 'stretch', height: 44 }}>
|
|
42
|
+
<SwiftUIDatePicker
|
|
43
|
+
title={label}
|
|
44
|
+
selection={value}
|
|
45
|
+
range={{ start: min, end: max }}
|
|
46
|
+
displayedComponents={[...COMPONENTS[mode]]}
|
|
47
|
+
onDateChange={(next) => onChange?.(next)}
|
|
48
|
+
modifiers={[datePickerStyle('compact'), tint(accent)]}
|
|
49
|
+
/>
|
|
50
|
+
</Host>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Alert as RNAlert, Platform } from 'react-native';
|
|
2
|
+
import { Host, Menu as SwiftUIMenu, Button as SwiftUIButton } from '@expo/ui/swift-ui';
|
|
3
|
+
import { tint } from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
import { useAccent } from './useTheme';
|
|
5
|
+
import { Button, type SFSymbol } from './Button';
|
|
6
|
+
|
|
7
|
+
export type MenuItem = {
|
|
8
|
+
label: string;
|
|
9
|
+
onPress?: () => void;
|
|
10
|
+
destructive?: boolean;
|
|
11
|
+
systemImage?: SFSymbol;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type MenuProps = {
|
|
15
|
+
label: string;
|
|
16
|
+
systemImage?: SFSymbol;
|
|
17
|
+
items: MenuItem[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Apple's own pull-down menu: a button that opens a list of actions.
|
|
22
|
+
*/
|
|
23
|
+
export function Menu({ label, systemImage, items }: MenuProps) {
|
|
24
|
+
const accent = useAccent();
|
|
25
|
+
|
|
26
|
+
if (Platform.OS !== 'ios') {
|
|
27
|
+
// ponytail: iOS-first product; Android lists the actions in a system alert.
|
|
28
|
+
return (
|
|
29
|
+
<Button
|
|
30
|
+
label={label}
|
|
31
|
+
variant="ghost"
|
|
32
|
+
onPress={() =>
|
|
33
|
+
RNAlert.alert(
|
|
34
|
+
label,
|
|
35
|
+
undefined,
|
|
36
|
+
items.map((item) => ({
|
|
37
|
+
text: item.label,
|
|
38
|
+
onPress: item.onPress,
|
|
39
|
+
style: item.destructive ? 'destructive' : 'default',
|
|
40
|
+
})),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Host matchContents>
|
|
49
|
+
<SwiftUIMenu label={label} systemImage={systemImage} modifiers={[tint(accent)]}>
|
|
50
|
+
{items.map((item) => (
|
|
51
|
+
<SwiftUIButton
|
|
52
|
+
key={item.label}
|
|
53
|
+
label={item.label}
|
|
54
|
+
systemImage={item.systemImage}
|
|
55
|
+
role={item.destructive ? 'destructive' : undefined}
|
|
56
|
+
onPress={item.onPress}
|
|
57
|
+
/>
|
|
58
|
+
))}
|
|
59
|
+
</SwiftUIMenu>
|
|
60
|
+
</Host>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { Modal as RNModal, View } from 'react-native';
|
|
1
|
+
import { Modal as RNModal, Platform, View, useWindowDimensions } from 'react-native';
|
|
2
2
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
+
import { BottomSheet, Group, Host, RNHostView } from '@expo/ui/swift-ui';
|
|
5
|
+
import { presentationDragIndicator } from '@expo/ui/swift-ui/modifiers';
|
|
4
6
|
import { useTheme } from './useTheme';
|
|
5
7
|
import { Text } from './Text';
|
|
6
8
|
|
|
@@ -12,25 +14,45 @@ export type ModalProps = {
|
|
|
12
14
|
};
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* — no hand-rolled overlay. onClose fires on both the swipe-dismiss (onRequestClose)
|
|
18
|
-
* and programmatic close.
|
|
17
|
+
* Apple's own bottom sheet (SwiftUI `sheet` with a grabber), sized to its content.
|
|
18
|
+
* onClose fires on swipe-dismiss and on programmatic close.
|
|
19
19
|
*/
|
|
20
20
|
export function Modal({ title, visible, onClose, children }: ModalProps) {
|
|
21
21
|
const theme = useTheme();
|
|
22
|
+
const { width } = useWindowDimensions();
|
|
23
|
+
|
|
24
|
+
const body = (
|
|
25
|
+
// ponytail: the hosted RN view is measured in both axes, so it gets the window width by hand
|
|
26
|
+
// (sheets are edge to edge on iPhone; on iPad this over-measures and the sheet clips).
|
|
27
|
+
<View style={{ width, padding: theme.space.lg, paddingBottom: theme.space.xl, gap: theme.space.md }}>
|
|
28
|
+
<Text size="headline">{title}</Text>
|
|
29
|
+
{children}
|
|
30
|
+
</View>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (Platform.OS === 'ios') {
|
|
34
|
+
return (
|
|
35
|
+
<Host matchContents>
|
|
36
|
+
<BottomSheet
|
|
37
|
+
isPresented={visible}
|
|
38
|
+
onIsPresentedChange={(open) => {
|
|
39
|
+
if (!open) onClose?.();
|
|
40
|
+
}}
|
|
41
|
+
fitToContents
|
|
42
|
+
>
|
|
43
|
+
<Group modifiers={[presentationDragIndicator('visible')]}>
|
|
44
|
+
<RNHostView matchContents>{body}</RNHostView>
|
|
45
|
+
</Group>
|
|
46
|
+
</BottomSheet>
|
|
47
|
+
</Host>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ponytail: iOS-first product; Android keeps the page sheet.
|
|
22
52
|
return (
|
|
23
|
-
<RNModal
|
|
24
|
-
visible={visible}
|
|
25
|
-
animationType="slide"
|
|
26
|
-
presentationStyle="pageSheet"
|
|
27
|
-
onRequestClose={onClose}
|
|
28
|
-
>
|
|
53
|
+
<RNModal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
|
|
29
54
|
<SafeAreaView style={{ flex: 1, backgroundColor: theme.surface.primary }} edges={['bottom']}>
|
|
30
|
-
|
|
31
|
-
<Text size="headline">{title}</Text>
|
|
32
|
-
{children}
|
|
33
|
-
</View>
|
|
55
|
+
{body}
|
|
34
56
|
</SafeAreaView>
|
|
35
57
|
</RNModal>
|
|
36
58
|
);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Platform, Pressable } from 'react-native';
|
|
2
|
+
import { Host, Picker as SwiftUIPicker, Text as SwiftUIText } from '@expo/ui/swift-ui';
|
|
3
|
+
import { pickerStyle, tag, tint } from '@expo/ui/swift-ui/modifiers';
|
|
4
|
+
import { useAccent, useTheme } from './useTheme';
|
|
5
|
+
import { Row } from './Row';
|
|
6
|
+
import { Text } from './Text';
|
|
7
|
+
|
|
8
|
+
export type PickerVariant = 'segmented' | 'menu' | 'wheel';
|
|
9
|
+
|
|
10
|
+
export type PickerProps = {
|
|
11
|
+
options: string[];
|
|
12
|
+
value: string;
|
|
13
|
+
onChange?: (value: string) => void;
|
|
14
|
+
label?: string;
|
|
15
|
+
variant?: PickerVariant;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Apple's own picker: segmented control by default, or a pull-down menu / wheel.
|
|
20
|
+
*/
|
|
21
|
+
export function Picker({ options, value, onChange, label, variant = 'segmented' }: PickerProps) {
|
|
22
|
+
const theme = useTheme();
|
|
23
|
+
const accent = useAccent();
|
|
24
|
+
|
|
25
|
+
if (Platform.OS === 'ios') {
|
|
26
|
+
return (
|
|
27
|
+
<Host style={{ alignSelf: 'stretch', height: variant === 'wheel' ? 200 : 36 }}>
|
|
28
|
+
<SwiftUIPicker
|
|
29
|
+
label={label}
|
|
30
|
+
selection={value}
|
|
31
|
+
onSelectionChange={(next) => onChange?.(String(next))}
|
|
32
|
+
modifiers={[pickerStyle(variant), tint(accent)]}
|
|
33
|
+
>
|
|
34
|
+
{options.map((option) => (
|
|
35
|
+
<SwiftUIText key={option} modifiers={[tag(option)]}>
|
|
36
|
+
{option}
|
|
37
|
+
</SwiftUIText>
|
|
38
|
+
))}
|
|
39
|
+
</SwiftUIPicker>
|
|
40
|
+
</Host>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ponytail: iOS-first product; Android shows a plain row of options.
|
|
45
|
+
return (
|
|
46
|
+
<Row gap={theme.space.md} align="center">
|
|
47
|
+
{label ? <Text size="body">{label}</Text> : null}
|
|
48
|
+
{options.map((option) => (
|
|
49
|
+
<Pressable key={option} onPress={() => onChange?.(option)}>
|
|
50
|
+
<Text size="label" color={option === value ? 'accent' : 'secondary'}>
|
|
51
|
+
{option}
|
|
52
|
+
</Text>
|
|
53
|
+
</Pressable>
|
|
54
|
+
))}
|
|
55
|
+
</Row>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { View, type ViewProps } from 'react-native';
|
|
2
|
-
import type
|
|
2
|
+
import { createContext, type ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
// Lets controls know they sit in a row, so they hug their content instead of filling the width.
|
|
5
|
+
export const RowContext = createContext(false);
|
|
3
6
|
|
|
4
7
|
export type RowProps = {
|
|
5
8
|
gap?: number;
|
|
@@ -22,7 +25,7 @@ export function Row({ gap = 0, align = 'start', style, children }: RowProps) {
|
|
|
22
25
|
style,
|
|
23
26
|
]}
|
|
24
27
|
>
|
|
25
|
-
{children}
|
|
28
|
+
<RowContext.Provider value>{children}</RowContext.Provider>
|
|
26
29
|
</View>
|
|
27
30
|
);
|
|
28
31
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native';
|
|
2
2
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
3
3
|
import type { ReactNode } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
5
|
+
import { useTheme, useAccent } from './useTheme';
|
|
5
6
|
|
|
6
7
|
export type ScreenProps = {
|
|
7
8
|
scrollable?: boolean;
|
|
9
|
+
gradient?: boolean | string;
|
|
8
10
|
children?: ReactNode;
|
|
9
11
|
};
|
|
10
12
|
|
|
@@ -21,10 +23,24 @@ export type ScreenProps = {
|
|
|
21
23
|
* Background lives on the outermost element so it covers bounce / inset areas.
|
|
22
24
|
* For Liquid Glass surfaces inside (cards, sheets), use Card with glass={true}
|
|
23
25
|
* — it wraps expo-glass-effect's GlassView, iOS 26's native material.
|
|
26
|
+
*
|
|
27
|
+
* gradient: a light wash of the accent (or the given colour) rising from the bottom edge and
|
|
28
|
+
* fading out towards the top. Sits behind the content.
|
|
24
29
|
*/
|
|
25
|
-
export function Screen({ scrollable = true, children }: ScreenProps) {
|
|
30
|
+
export function Screen({ scrollable = true, gradient, children }: ScreenProps) {
|
|
26
31
|
const theme = useTheme();
|
|
32
|
+
const accent = useAccent();
|
|
27
33
|
const padding = theme.space.md;
|
|
34
|
+
const washColor = gradient === true ? accent : gradient;
|
|
35
|
+
// A 6-digit hex fades from its own zero-alpha; plain 'transparent' greys out mid-way.
|
|
36
|
+
const washStart = washColor && /^#[0-9a-f]{6}$/i.test(washColor) ? `${washColor}00` : 'transparent';
|
|
37
|
+
const wash = washColor ? (
|
|
38
|
+
<LinearGradient
|
|
39
|
+
pointerEvents="none"
|
|
40
|
+
colors={[washStart, washColor]}
|
|
41
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0.1 }}
|
|
42
|
+
/>
|
|
43
|
+
) : null;
|
|
28
44
|
|
|
29
45
|
if (scrollable) {
|
|
30
46
|
return (
|
|
@@ -33,6 +49,7 @@ export function Screen({ scrollable = true, children }: ScreenProps) {
|
|
|
33
49
|
contentContainerStyle={{ padding, gap: padding }}
|
|
34
50
|
contentInsetAdjustmentBehavior="automatic"
|
|
35
51
|
>
|
|
52
|
+
{wash}
|
|
36
53
|
{children}
|
|
37
54
|
</ScrollView>
|
|
38
55
|
);
|
|
@@ -43,6 +60,7 @@ export function Screen({ scrollable = true, children }: ScreenProps) {
|
|
|
43
60
|
style={{ flex: 1, backgroundColor: theme.surface.primary }}
|
|
44
61
|
edges={['top', 'bottom', 'left', 'right']}
|
|
45
62
|
>
|
|
63
|
+
{wash}
|
|
46
64
|
<KeyboardAvoidingView
|
|
47
65
|
style={{ flex: 1 }}
|
|
48
66
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
@@ -3,7 +3,7 @@ export { Stack, type StackProps } from './Stack';
|
|
|
3
3
|
export { Row, type RowProps } from './Row';
|
|
4
4
|
export { Text, type TextProps, type TextSize, type TextColor } from './Text';
|
|
5
5
|
export { Card, type CardProps } from './Card';
|
|
6
|
-
export { Button, type ButtonProps, type ButtonVariant } from './Button';
|
|
6
|
+
export { Button, type ButtonProps, type ButtonVariant, type SFSymbol } from './Button';
|
|
7
7
|
export { Toggle, type ToggleProps } from './Toggle';
|
|
8
8
|
export { Slider, type SliderProps } from './Slider';
|
|
9
9
|
export { Stepper, type StepperProps } from './Stepper';
|
|
@@ -11,6 +11,10 @@ export { Divider, type DividerProps } from './Divider';
|
|
|
11
11
|
export { Input, type InputProps } from './Input';
|
|
12
12
|
export { Lottie, type LottieProps } from './Lottie';
|
|
13
13
|
export { Modal, type ModalProps } from './Modal';
|
|
14
|
+
export { Picker, type PickerProps, type PickerVariant } from './Picker';
|
|
15
|
+
export { DatePicker, type DatePickerProps, type DatePickerMode } from './DatePicker';
|
|
16
|
+
export { Menu, type MenuProps, type MenuItem } from './Menu';
|
|
17
|
+
export { Alert, type AlertProps, type AlertAction } from './Alert';
|
|
14
18
|
export { useTheme, useAccent } from './useTheme';
|
|
15
19
|
export { ProtoConfigProvider, useProtoConfig } from './ProtoConfigContext';
|
|
16
20
|
export { mock } from './mock';
|
|
@@ -1,17 +1,242 @@
|
|
|
1
1
|
// Proto-managed. Draws a round dot wherever you touch WHILE `proto record` is
|
|
2
2
|
// running, so taps are visible in the recorded video (the recorder captures
|
|
3
|
-
// only what the app itself renders).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
// only what the app itself renders). It also answers Prototo Desktop's
|
|
4
|
+
// point-and-edit: a tapped preview element is resolved to the screen file and
|
|
5
|
+
// line that renders it. And its screen-flow view: a clicked screen opens here. Dev-only twice over: everything is gated on __DEV__,
|
|
6
|
+
// and published shares are production bundles where __DEV__ is false —
|
|
7
|
+
// stakeholders can never see it. Safe to leave alone.
|
|
8
|
+
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { Animated, Dimensions, type GestureResponderEvent, View } from 'react-native';
|
|
8
10
|
|
|
9
|
-
const POLL_MS =
|
|
11
|
+
const POLL_MS = 500;
|
|
10
12
|
const DOT = 36;
|
|
11
13
|
// A quick tap must linger long enough to be readable in the video.
|
|
12
14
|
const FADE_MS = 350;
|
|
13
15
|
|
|
14
16
|
type Dot = { id: number; x: number; y: number };
|
|
17
|
+
type InspectRequest = { id: number; x: number; y: number };
|
|
18
|
+
type NavigateRequest = { id: number; path: string };
|
|
19
|
+
type LinksRequest = { id: number };
|
|
20
|
+
|
|
21
|
+
// Flow view: open the route the desktop asked for, then confirm. expo-router is
|
|
22
|
+
// required at run time (every Prototo project has it; this file's own package
|
|
23
|
+
// doesn't), so a missing router is just a "no".
|
|
24
|
+
function navigateTo(req: NavigateRequest) {
|
|
25
|
+
let ok = false;
|
|
26
|
+
try {
|
|
27
|
+
const { router } = require('expo-router') as {
|
|
28
|
+
router: {
|
|
29
|
+
navigate: (href: string) => void;
|
|
30
|
+
canDismiss?: () => boolean;
|
|
31
|
+
dismissAll?: () => void;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
// unwind the current stack first: navigating from a sheet to the screen
|
|
35
|
+
// under it pushed a second copy, and the flow walk (#89) wants each screen
|
|
36
|
+
// once, in its resting place. Only when there is something to dismiss: an
|
|
37
|
+
// unhandled POP_TO_TOP shows a red dev toast that lands in the screenshots.
|
|
38
|
+
if (router.canDismiss?.()) router.dismissAll?.();
|
|
39
|
+
router.navigate(req.path);
|
|
40
|
+
ok = true;
|
|
41
|
+
} catch {
|
|
42
|
+
// unknown route or no router — the desktop already pasted the file
|
|
43
|
+
}
|
|
44
|
+
fetch('http://127.0.0.1:3001/navigate/result', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify({ id: req.id, ok }),
|
|
48
|
+
}).catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
type DebugFiber = { _debugStack?: { stack?: unknown }; _debugOwner?: DebugFiber | null };
|
|
51
|
+
|
|
52
|
+
// Point-and-edit. React keeps, in dev, the JSX call site of every element on
|
|
53
|
+
// its fiber (`_debugStack`); walking the owner chain from the tapped view gives
|
|
54
|
+
// the designer's screen file first. `proto start` symbolicates the stacks. The
|
|
55
|
+
// renderer's inspector is reached through the DevTools hook — the same thing
|
|
56
|
+
// React Native's own element inspector does, without the deprecated deep import.
|
|
57
|
+
type InspectorData = { hierarchy?: unknown[]; closestInstance?: DebugFiber | null };
|
|
58
|
+
type Renderer = {
|
|
59
|
+
rendererConfig?: {
|
|
60
|
+
getInspectorDataForViewAtPoint?: (
|
|
61
|
+
view: View | null,
|
|
62
|
+
x: number,
|
|
63
|
+
y: number,
|
|
64
|
+
cb: (data: InspectorData) => boolean,
|
|
65
|
+
) => void;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function inspectAt(view: View | null, req: InspectRequest) {
|
|
70
|
+
let answered = false;
|
|
71
|
+
const post = (stacks: string[]) => {
|
|
72
|
+
if (answered) return;
|
|
73
|
+
answered = true;
|
|
74
|
+
fetch('http://127.0.0.1:3001/inspect/result', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ id: req.id, stacks }),
|
|
78
|
+
}).catch(() => {});
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
const hook = (
|
|
82
|
+
globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: { renderers: Map<number, Renderer> } }
|
|
83
|
+
).__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
84
|
+
const { width, height } = Dimensions.get('window');
|
|
85
|
+
for (const renderer of hook?.renderers.values() ?? []) {
|
|
86
|
+
renderer.rendererConfig?.getInspectorDataForViewAtPoint?.(
|
|
87
|
+
view,
|
|
88
|
+
req.x * width,
|
|
89
|
+
req.y * height,
|
|
90
|
+
(data) => {
|
|
91
|
+
if (!data.hierarchy?.length) return false;
|
|
92
|
+
const stacks: string[] = [];
|
|
93
|
+
let fiber: DebugFiber | null | undefined = data.closestInstance;
|
|
94
|
+
for (let i = 0; fiber && i < 12; i++) {
|
|
95
|
+
const stack = fiber._debugStack?.stack;
|
|
96
|
+
if (typeof stack === 'string') stacks.push(stack);
|
|
97
|
+
fiber = fiber._debugOwner;
|
|
98
|
+
}
|
|
99
|
+
post(stacks);
|
|
100
|
+
return true;
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// no renderer / not a dev build — the CLI times out and the desktop
|
|
106
|
+
// falls back to a label-only reference
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Flow export (#89): every tappable on the mounted screen, with where it sits,
|
|
110
|
+
// so the flow's arrows can start at the button. A `<Link href>` names its
|
|
111
|
+
// target outright; a `router.push` inside an onPress can't be read here, so
|
|
112
|
+
// its debug stack goes along and `proto flow` matches it to the source. Walks
|
|
113
|
+
// the fiber tree from the renderer's roots (DevTools hook again), measures the
|
|
114
|
+
// first host view under each candidate. Screens under the top one stay mounted
|
|
115
|
+
// (a stack keeps its history, tabs keep every tab) and measure to the same
|
|
116
|
+
// place, so `proto flow` keeps only the links whose source is the screen's own
|
|
117
|
+
// file. Fails open: no answer = no anchors.
|
|
118
|
+
type Fiber = DebugFiber & {
|
|
119
|
+
tag?: number;
|
|
120
|
+
memoizedProps?: { href?: unknown; onPress?: unknown } | null;
|
|
121
|
+
stateNode?: unknown;
|
|
122
|
+
child?: Fiber | null;
|
|
123
|
+
sibling?: Fiber | null;
|
|
124
|
+
return?: Fiber | null;
|
|
125
|
+
alternate?: Fiber | null;
|
|
126
|
+
};
|
|
127
|
+
type Hook = {
|
|
128
|
+
renderers: Map<number, Renderer>;
|
|
129
|
+
getFiberRoots?: (id: number) => Set<{ current: Fiber }>;
|
|
130
|
+
};
|
|
131
|
+
type Measurable = {
|
|
132
|
+
measureInWindow: (cb: (x: number, y: number, w: number, h: number) => void) => void;
|
|
133
|
+
};
|
|
134
|
+
type FoundLink = {
|
|
135
|
+
href?: string;
|
|
136
|
+
stacks: string[];
|
|
137
|
+
frame: { x: number; y: number; w: number; h: number };
|
|
138
|
+
};
|
|
139
|
+
const MAX_LINKS = 60;
|
|
140
|
+
|
|
141
|
+
// Fabric keeps the public instance a level or two under the fiber's stateNode.
|
|
142
|
+
function measurableOf(node: unknown): Measurable | null {
|
|
143
|
+
const sn = node as { canonical?: { publicInstance?: unknown }; publicInstance?: unknown } | null;
|
|
144
|
+
for (const c of [sn, sn?.canonical?.publicInstance, sn?.publicInstance]) {
|
|
145
|
+
if (c && typeof (c as Measurable).measureInWindow === 'function') return c as Measurable;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hostViewOf(fiber: Fiber): Measurable | null {
|
|
151
|
+
for (let f: Fiber | null | undefined = fiber, i = 0; f && i < 8; f = f.child, i++) {
|
|
152
|
+
if (f.tag === 5) return measurableOf(f.stateNode);
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function collectLinks(req: LinksRequest) {
|
|
158
|
+
const post = (links: FoundLink[]) =>
|
|
159
|
+
fetch('http://127.0.0.1:3001/links/result', {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify({ id: req.id, links }),
|
|
163
|
+
}).catch(() => {});
|
|
164
|
+
try {
|
|
165
|
+
const hook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: Hook })
|
|
166
|
+
.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
167
|
+
if (!hook) {
|
|
168
|
+
void post([]);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const { width, height } = Dimensions.get('window');
|
|
172
|
+
const candidates: { fiber: Fiber; href?: string }[] = [];
|
|
173
|
+
const seen = new Set<unknown>();
|
|
174
|
+
for (const id of hook.renderers.keys()) {
|
|
175
|
+
for (const root of hook.getFiberRoots?.(id) ?? []) {
|
|
176
|
+
const stack: Fiber[] = [root.current];
|
|
177
|
+
while (stack.length && candidates.length < MAX_LINKS) {
|
|
178
|
+
const f = stack.pop() as Fiber;
|
|
179
|
+
const props = f.memoizedProps;
|
|
180
|
+
const href =
|
|
181
|
+
typeof props?.href === 'string' && props.href.startsWith('/') ? props.href : undefined;
|
|
182
|
+
if (href || typeof props?.onPress === 'function') {
|
|
183
|
+
// the same onPress rides down Button → Pressable → host; keep the outermost
|
|
184
|
+
const key = href ?? props?.onPress;
|
|
185
|
+
if (!seen.has(key)) {
|
|
186
|
+
seen.add(key);
|
|
187
|
+
candidates.push({ fiber: f, href });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (f.sibling) stack.push(f.sibling);
|
|
191
|
+
if (f.child) stack.push(f.child);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const measured = candidates.map(
|
|
196
|
+
(c) =>
|
|
197
|
+
new Promise<FoundLink | null>((resolve) => {
|
|
198
|
+
const host = hostViewOf(c.fiber);
|
|
199
|
+
if (!host) return resolve(null);
|
|
200
|
+
const measure = () => new Promise<number[]>((r) => host.measureInWindow((...m) => r(m)));
|
|
201
|
+
// measured twice, a beat apart: a frame still moving (a sheet being
|
|
202
|
+
// dismissed, a screen scaling back) is not a place to anchor an arrow
|
|
203
|
+
void (async () => {
|
|
204
|
+
const a = await measure();
|
|
205
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
206
|
+
const b = await measure();
|
|
207
|
+
if (a.some((v, i) => Math.abs(v - (b[i] ?? 0)) > 1)) return resolve(null);
|
|
208
|
+
const [x, y, w, h] = b as [number, number, number, number];
|
|
209
|
+
if (!(w > 0 && h > 0) || y + h <= 0 || y >= height) return resolve(null); // off screen
|
|
210
|
+
const stacks: string[] = [];
|
|
211
|
+
let fiber: DebugFiber | null | undefined = c.fiber;
|
|
212
|
+
for (let i = 0; fiber && i < 12; i++) {
|
|
213
|
+
const s = fiber._debugStack?.stack;
|
|
214
|
+
if (typeof s === 'string') stacks.push(s);
|
|
215
|
+
fiber = fiber._debugOwner;
|
|
216
|
+
}
|
|
217
|
+
// clipped to the screen: a card half below the fold anchors at its visible part
|
|
218
|
+
const [x0, y0, x1, y1] = [
|
|
219
|
+
Math.max(0, x),
|
|
220
|
+
Math.max(0, y),
|
|
221
|
+
Math.min(width, x + w),
|
|
222
|
+
Math.min(height, y + h),
|
|
223
|
+
];
|
|
224
|
+
resolve({
|
|
225
|
+
...(c.href ? { href: c.href } : {}),
|
|
226
|
+
stacks,
|
|
227
|
+
frame: { x: x0 / width, y: y0 / height, w: (x1 - x0) / width, h: (y1 - y0) / height },
|
|
228
|
+
});
|
|
229
|
+
})();
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
const timeout = new Promise<null>((r) => setTimeout(() => r(null), 1200));
|
|
233
|
+
void Promise.all(measured.map((m) => Promise.race([m, timeout]))).then((links) =>
|
|
234
|
+
post(links.filter((l): l is FoundLink => l !== null)),
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
void post([]);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
15
240
|
type FadingDot = { key: number; x: number; y: number; opacity: Animated.Value };
|
|
16
241
|
|
|
17
242
|
// Brand-pink fill + white rim: reads on light AND dark content (a white or
|
|
@@ -32,6 +257,10 @@ export default function TouchDots({ children }: { children: ReactNode }) {
|
|
|
32
257
|
const [fading, setFading] = useState<FadingDot[]>([]);
|
|
33
258
|
const dotsRef = useRef<Dot[]>([]);
|
|
34
259
|
const fadeSeq = useRef(0);
|
|
260
|
+
const rootRef = useRef<View>(null);
|
|
261
|
+
const inspected = useRef(0);
|
|
262
|
+
const navigated = useRef(0);
|
|
263
|
+
const linked = useRef(0);
|
|
35
264
|
|
|
36
265
|
// Poll `proto start`'s local server for the record flag (the Simulator
|
|
37
266
|
// shares the host loopback). Any failure just means "not recording".
|
|
@@ -41,8 +270,26 @@ export default function TouchDots({ children }: { children: ReactNode }) {
|
|
|
41
270
|
const tick = async () => {
|
|
42
271
|
try {
|
|
43
272
|
const res = await fetch('http://127.0.0.1:3001/recording');
|
|
44
|
-
const body = (await res.json()) as {
|
|
45
|
-
|
|
273
|
+
const body = (await res.json()) as {
|
|
274
|
+
recording?: boolean;
|
|
275
|
+
inspect?: InspectRequest;
|
|
276
|
+
navigate?: NavigateRequest;
|
|
277
|
+
links?: LinksRequest;
|
|
278
|
+
};
|
|
279
|
+
if (!alive) return;
|
|
280
|
+
setRecording(body.recording === true);
|
|
281
|
+
if (body.inspect && body.inspect.id !== inspected.current) {
|
|
282
|
+
inspected.current = body.inspect.id;
|
|
283
|
+
inspectAt(rootRef.current, body.inspect);
|
|
284
|
+
}
|
|
285
|
+
if (body.navigate && body.navigate.id !== navigated.current) {
|
|
286
|
+
navigated.current = body.navigate.id;
|
|
287
|
+
navigateTo(body.navigate);
|
|
288
|
+
}
|
|
289
|
+
if (body.links && body.links.id !== linked.current) {
|
|
290
|
+
linked.current = body.links.id;
|
|
291
|
+
collectLinks(body.links);
|
|
292
|
+
}
|
|
46
293
|
} catch {
|
|
47
294
|
if (alive) setRecording(false);
|
|
48
295
|
}
|
|
@@ -98,6 +345,7 @@ export default function TouchDots({ children }: { children: ReactNode }) {
|
|
|
98
345
|
// Plain touch events bubble to this wrapper no matter which child is the
|
|
99
346
|
// responder, so observing them here never steals the prototype's gestures.
|
|
100
347
|
<View
|
|
348
|
+
ref={rootRef}
|
|
101
349
|
style={{ flex: 1 }}
|
|
102
350
|
onTouchStart={recording ? onMove : undefined}
|
|
103
351
|
onTouchMove={recording ? onMove : undefined}
|
package/template/gitignore
CHANGED
package/template/package.json
CHANGED
|
@@ -9,49 +9,50 @@
|
|
|
9
9
|
"proto": "proto"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@expo/ui": "~
|
|
12
|
+
"@expo/ui": "~57.0.18",
|
|
13
13
|
"@react-native-async-storage/async-storage": "2.2.0",
|
|
14
14
|
"@shopify/flash-list": "2.0.2",
|
|
15
15
|
"@shopify/react-native-skia": "2.6.2",
|
|
16
|
-
"expo": "~
|
|
17
|
-
"expo-asset": "~
|
|
18
|
-
"expo-audio": "~
|
|
19
|
-
"expo-camera": "~
|
|
20
|
-
"expo-clipboard": "~
|
|
21
|
-
"expo-constants": "~
|
|
22
|
-
"expo-dev-client": "~
|
|
23
|
-
"expo-font": "~
|
|
24
|
-
"expo-glass-effect": "~
|
|
25
|
-
"expo-haptics": "~
|
|
26
|
-
"expo-image": "~
|
|
27
|
-
"expo-image-picker": "~
|
|
28
|
-
"expo-linear-gradient": "~
|
|
29
|
-
"expo-linking": "~
|
|
30
|
-
"expo-location": "~
|
|
31
|
-
"expo-maps": "~
|
|
32
|
-
"expo-router": "~
|
|
33
|
-
"expo-screen-orientation": "~
|
|
34
|
-
"expo-
|
|
35
|
-
"expo-
|
|
36
|
-
"expo-
|
|
16
|
+
"expo": "~57.0.0",
|
|
17
|
+
"expo-asset": "~57.0.17",
|
|
18
|
+
"expo-audio": "~57.0.5",
|
|
19
|
+
"expo-camera": "~57.0.5",
|
|
20
|
+
"expo-clipboard": "~57.0.2",
|
|
21
|
+
"expo-constants": "~57.0.18",
|
|
22
|
+
"expo-dev-client": "~57.0.19",
|
|
23
|
+
"expo-font": "~57.0.4",
|
|
24
|
+
"expo-glass-effect": "~57.0.3",
|
|
25
|
+
"expo-haptics": "~57.0.3",
|
|
26
|
+
"expo-image": "~57.0.5",
|
|
27
|
+
"expo-image-picker": "~57.0.18",
|
|
28
|
+
"expo-linear-gradient": "~57.0.2",
|
|
29
|
+
"expo-linking": "~57.0.10",
|
|
30
|
+
"expo-location": "~57.0.18",
|
|
31
|
+
"expo-maps": "~57.0.3",
|
|
32
|
+
"expo-router": "~57.0.21",
|
|
33
|
+
"expo-screen-orientation": "~57.0.2",
|
|
34
|
+
"expo-sensors": "~57.0.3",
|
|
35
|
+
"expo-status-bar": "~57.0.1",
|
|
36
|
+
"expo-symbols": "~57.0.3",
|
|
37
|
+
"expo-video": "~57.0.4",
|
|
37
38
|
"lottie-react-native": "~7.3.4",
|
|
38
39
|
"react": "19.2.3",
|
|
39
40
|
"react-dom": "19.2.3",
|
|
40
|
-
"react-native": "0.
|
|
41
|
+
"react-native": "0.86.3",
|
|
41
42
|
"react-native-ease": "^0.7.2",
|
|
42
|
-
"react-native-gesture-handler": "~2.
|
|
43
|
-
"react-native-reanimated": "4.
|
|
43
|
+
"react-native-gesture-handler": "~2.32.0",
|
|
44
|
+
"react-native-reanimated": "4.5.1",
|
|
44
45
|
"react-native-safe-area-context": "~5.7.0",
|
|
45
|
-
"react-native-screens": "4.
|
|
46
|
+
"react-native-screens": "4.26.2",
|
|
46
47
|
"react-native-svg": "15.15.4",
|
|
47
48
|
"react-native-webview": "13.16.1",
|
|
48
|
-
"react-native-worklets": "0.
|
|
49
|
+
"react-native-worklets": "0.10.1"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@babel/core": "^7.24.0",
|
|
52
|
-
"@sherizan/proto-cli": "^0.
|
|
53
|
+
"@sherizan/proto-cli": "^0.8.0",
|
|
53
54
|
"@types/react": "~19.2.15",
|
|
54
|
-
"babel-preset-expo": "~
|
|
55
|
+
"babel-preset-expo": "~57.0.0",
|
|
55
56
|
"react-native-svg-transformer": "^1.5.0",
|
|
56
57
|
"typescript": "~6.0.3"
|
|
57
58
|
}
|