├── src ├── components │ ├── runtime │ │ ├── local-storage.web.ts │ │ ├── local-storage.ts │ │ └── apple-css-variables.ts │ ├── layout │ │ ├── modalNavigator.tsx │ │ ├── modal.module.css │ │ └── modalNavigator.web.tsx │ ├── ui │ │ ├── TouchableBounce.web.tsx │ │ ├── IconSymbol.tsx │ │ ├── TabBarBackground.tsx │ │ ├── FadeIn.tsx │ │ ├── TabBarBackground.ios.tsx │ │ ├── IconSymbol.ios.tsx │ │ ├── BodyScrollView.tsx │ │ ├── Header.tsx │ │ ├── ThemeProvider.tsx │ │ ├── TouchableBounce.tsx │ │ ├── Skeleton.web.tsx │ │ ├── Stack.tsx │ │ ├── Tabs.tsx │ │ ├── ContentUnavailable.tsx │ │ ├── Skeleton.tsx │ │ ├── Segments.tsx │ │ ├── Form.tsx │ │ └── IconSymbolFallback.tsx │ ├── data │ │ └── async-font.tsx │ ├── example │ │ ├── privacy-dom.tsx │ │ └── glurry-modal.tsx │ └── torus-dom.tsx ├── svg │ ├── expo.svg │ └── github.svg ├── app │ ├── (index,info) │ │ ├── privacy.tsx │ │ ├── two.tsx │ │ ├── info.tsx │ │ ├── _layout.tsx │ │ ├── icon.tsx │ │ ├── account.tsx │ │ ├── _debug.tsx │ │ └── index.tsx │ ├── +html.tsx │ └── _layout.tsx └── hooks │ ├── useMergedRef.ts │ ├── useHeaderSearch.ts │ └── useTabToTop.ts ├── .env ├── bun.lockb ├── assets └── images │ ├── icon.png │ ├── favicon.png │ ├── adaptive-icon.png │ └── splash-icon.png ├── global.d.ts ├── tsconfig.json ├── eslint.config.js ├── eas.json ├── .vscode └── settings.json ├── .gitignore ├── README.md ├── metro.config.js ├── app.json ├── metro.transformer.js ├── patches └── react-server-dom-webpack+19.0.0.patch └── package.json /src/components/runtime/local-storage.web.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | EXPO_UNSTABLE_DEPLOY_SERVER=1 2 | EXPO_METRO_UNSTABLE_ERRORS=0 -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/HEAD/bun.lockb -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /src/components/layout/modalNavigator.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | export default Stack; 4 | -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EvanBacon/expo-beautiful-torus/HEAD/assets/images/splash-icon.png -------------------------------------------------------------------------------- /src/components/ui/TouchableBounce.web.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TouchableOpacity } from "react-native"; 4 | 5 | export default TouchableOpacity; 6 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | import React from "react"; 3 | import { SvgProps } from "react-native-svg"; 4 | const content: React.FC; 5 | export default content; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/ui/IconSymbol.tsx: -------------------------------------------------------------------------------- 1 | import { IconSymbolMaterial, IconSymbolName } from "./IconSymbolFallback"; 2 | 3 | export const IconSymbol = IconSymbolMaterial; 4 | 5 | export { IconSymbolName }; 6 | -------------------------------------------------------------------------------- /src/components/ui/TabBarBackground.tsx: -------------------------------------------------------------------------------- 1 | // This is a shim for web and Android where the tab bar is generally opaque. 2 | export default undefined; 3 | 4 | export function useBottomTabOverflow() { 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["src/*"] 8 | }, 9 | "typeRoots": ["./global.d.ts"] 10 | }, 11 | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /src/components/ui/FadeIn.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Animated, { FadeIn as EnterFadeIn } from "react-native-reanimated"; 4 | 5 | export function FadeIn({ children }: { children: React.ReactNode }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // https://docs.expo.dev/guides/using-eslint/ 2 | const { defineConfig } = require("eslint/config"); 3 | const expoConfig = require("eslint-config-expo/flat"); 4 | 5 | module.exports = defineConfig([ 6 | expoConfig, 7 | { 8 | ignores: ["dist/*"], 9 | plugins: ["react-compiler"], 10 | rules: { 11 | "react-compiler/react-compiler": "error", 12 | }, 13 | }, 14 | ]); 15 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 16.6.1", 4 | "appVersionSource": "remote" 5 | }, 6 | "build": { 7 | "development": { 8 | "developmentClient": true, 9 | "distribution": "internal" 10 | }, 11 | "preview": { 12 | "distribution": "internal" 13 | }, 14 | "production": { 15 | "autoIncrement": true 16 | } 17 | }, 18 | "submit": { 19 | "production": {} 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.fixAll": "explicit", 5 | "source.organizeImports": "explicit", 6 | "source.sortMembers": "explicit" 7 | }, 8 | "typescript.preferences.autoImportSpecifierExcludeRegexes": [ 9 | "^react-native-reanimated/lib/*", 10 | "^expo-router/build/*" 11 | ], 12 | "typescript.tsdk": "node_modules/typescript/lib" 13 | } 14 | -------------------------------------------------------------------------------- /src/svg/expo.svg: -------------------------------------------------------------------------------- 1 | Expo icon -------------------------------------------------------------------------------- /src/app/(index,info)/privacy.tsx: -------------------------------------------------------------------------------- 1 | import PrivacyPolicy from "@/components/example/privacy-dom"; 2 | import { Stack } from "expo-router"; 3 | import Head from "expo-router/head"; 4 | 5 | export default function Privacy() { 6 | return ( 7 | <> 8 | 9 | Privacy | Torus 10 | 11 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Torus 2 | 3 | 4 | **_“You know a design is good when you want to touch it.”_** 5 | 6 | ~ Jonathan Ive 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ## Launch your own 15 | 16 | [![Launch with Expo](https://github.com/expo/examples/blob/master/.gh-assets/launch.svg?raw=true)](https://launch.expo.dev/?github=https://github.com/EvanBacon/expo-beautiful-torus) 17 | 18 | ## Preview 19 | 20 | https://github.com/user-attachments/assets/5927b81f-a3ba-424f-92c0-6965389abffd 21 | 22 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require("expo/metro-config"); 3 | 4 | /** @type {import('expo/metro-config').MetroConfig} */ 5 | const config = getDefaultConfig(__dirname); 6 | 7 | config.resolver.sourceExts.push("svg"); 8 | config.resolver.assetExts = config.resolver.assetExts.filter( 9 | (ext) => ext !== "svg" 10 | ); 11 | 12 | config.transformer.babelTransformerPath = require.resolve( 13 | "./metro.transformer.js" 14 | ); 15 | 16 | config.transformer.getTransformOptions = async () => ({ 17 | transform: { 18 | experimentalImportSupport: true, 19 | }, 20 | }); 21 | 22 | module.exports = config; 23 | -------------------------------------------------------------------------------- /src/hooks/useMergedRef.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | type CallbackRef = (ref: T) => any; 4 | type ObjectRef = { current: T }; 5 | 6 | type Ref = CallbackRef | ObjectRef; 7 | 8 | export default function useMergedRef( 9 | ...refs: (Ref | undefined)[] 10 | ): CallbackRef { 11 | return useCallback( 12 | (current: T) => { 13 | for (const ref of refs) { 14 | if (ref != null) { 15 | if (typeof ref === "function") { 16 | ref(current); 17 | } else { 18 | ref.current = current; 19 | } 20 | } 21 | } 22 | }, 23 | // eslint-disable-next-line react-hooks/exhaustive-deps 24 | [...refs] 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/ui/TabBarBackground.ios.tsx: -------------------------------------------------------------------------------- 1 | import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs"; 2 | import { BlurView } from "expo-blur"; 3 | import { StyleSheet } from "react-native"; 4 | 5 | export default function BlurTabBarBackground() { 6 | return ( 7 | 14 | ); 15 | } 16 | 17 | export function useBottomTabOverflow() { 18 | let tabHeight = 0; 19 | try { 20 | // eslint-disable-next-line react-hooks/rules-of-hooks 21 | tabHeight = useBottomTabBarHeight(); 22 | } catch {} 23 | 24 | return tabHeight; 25 | } 26 | -------------------------------------------------------------------------------- /src/svg/github.svg: -------------------------------------------------------------------------------- 1 | 3 | GitHub 4 | 5 | -------------------------------------------------------------------------------- /src/components/ui/IconSymbol.ios.tsx: -------------------------------------------------------------------------------- 1 | import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"; 2 | import { StyleProp, ViewStyle } from "react-native"; 3 | 4 | export function IconSymbol({ 5 | name, 6 | size = 24, 7 | color, 8 | style, 9 | weight = "regular", 10 | animationSpec, 11 | }: { 12 | name: SymbolViewProps["name"]; 13 | size?: number; 14 | color: string; 15 | style?: StyleProp; 16 | weight?: SymbolWeight; 17 | animationSpec?: SymbolViewProps["animationSpec"]; 18 | }) { 19 | return ( 20 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useHeaderSearch.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { useEffect, useState } from "react"; 4 | import { useNavigation } from "expo-router"; 5 | import { SearchBarProps } from "react-native-screens"; 6 | 7 | export function useHeaderSearch(options: Omit = {}) { 8 | const [search, setSearch] = useState(""); 9 | const navigation = useNavigation(); 10 | 11 | useEffect(() => { 12 | const interceptedOptions: SearchBarProps = { 13 | ...options, 14 | onChangeText(event) { 15 | setSearch(event.nativeEvent.text); 16 | options.onChangeText?.(event); 17 | }, 18 | onSearchButtonPress(e) { 19 | setSearch(e.nativeEvent.text); 20 | options.onSearchButtonPress?.(e); 21 | }, 22 | onCancelButtonPress(e) { 23 | setSearch(""); 24 | options.onCancelButtonPress?.(e); 25 | }, 26 | }; 27 | 28 | navigation.setOptions({ 29 | headerShown: true, 30 | headerSearchBarOptions: interceptedOptions, 31 | }); 32 | }, [options, navigation]); 33 | 34 | return search; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/layout/modal.module.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | display: flex; 3 | flex: 1; 4 | pointer-events: auto; 5 | background-color: var(--apple-systemGroupedBackground); 6 | border: 1px solid var(--apple-separator); /* Replace with your separator variable */ 7 | } 8 | 9 | @media (min-width: 768px) { 10 | .modal { 11 | position: fixed; 12 | left: 50%; 13 | top: 50%; 14 | z-index: 50; 15 | width: 100%; 16 | max-width: 55rem; 17 | min-height: 50rem; 18 | transform: translate(-50%, -50%); 19 | box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 20 | 0 4px 6px -4px rgb(0 0 0 / 0.1); /* Replace with your shadow variable */ 21 | max-height: 80%; 22 | overflow: scroll; 23 | border-radius: 0.5rem; /* Equivalent to sm:rounded-lg */ 24 | outline: none; 25 | } 26 | } 27 | 28 | .drawerContent { 29 | position: fixed; 30 | display: flex; 31 | flex-direction: column; 32 | bottom: 0; 33 | left: 0; 34 | right: 0; 35 | border-radius: 8px 8px 0 0; 36 | overflow: hidden; 37 | height: 100%; 38 | max-height: 97%; 39 | outline: none; 40 | 41 | } 42 | 43 | @media (min-width: 768px) { 44 | .drawerContent { 45 | max-height: 100%; 46 | /* pointer-events: box-none; */ 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/components/ui/BodyScrollView.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useScrollToTop } from "@/hooks/useTabToTop"; 4 | import * as AC from "@bacons/apple-colors"; 5 | import { ScrollViewProps } from "react-native"; 6 | import Animated from "react-native-reanimated"; 7 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 8 | import { useBottomTabOverflow } from "./TabBarBackground"; 9 | 10 | export function BodyScrollView( 11 | props: ScrollViewProps & { ref?: React.Ref } 12 | ) { 13 | const paddingBottom = useBottomTabOverflow(); 14 | 15 | const { top: statusBarInset, bottom } = useSafeAreaInsets(); // inset of the status bar 16 | 17 | const largeHeaderInset = statusBarInset + 92; // inset to use for a large header since it's frame is equal to 96 + the frame of status bar 18 | 19 | useScrollToTop(props.ref!, -largeHeaderInset); 20 | 21 | return ( 22 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/+html.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollViewStyleReset } from "expo-router/html"; 2 | import { type PropsWithChildren } from "react"; 3 | 4 | // This file is web-only and used to configure the root HTML for every 5 | // web page during static rendering. 6 | // The contents of this function only run in Node.js environments and 7 | // do not have access to the DOM or browser APIs. 8 | export default function Root({ children }: PropsWithChildren) { 9 | return ( 10 | 11 | 12 | 13 | 14 | 18 | 19 | {/* 20 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. 21 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. 22 | */} 23 | 24 | {/* 88 | 89 | ); 90 | }; 91 | 92 | export default Skeleton; 93 | -------------------------------------------------------------------------------- /patches/react-server-dom-webpack+19.0.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 2 | index 38e04fb..24cc8ef 100644 3 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 4 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js 5 | @@ -800,9 +800,9 @@ 6 | return bound 7 | ? "fulfilled" === bound.status 8 | ? callServer(id, bound.value.concat(args)) 9 | - : Promise.resolve(bound).then(function (boundArgs) { 10 | - return callServer(id, boundArgs.concat(args)); 11 | - }) 12 | + // HACK: This is required to make native server actions return a non-undefined value. 13 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web. 14 | + : (async () => callServer(id, (await bound).concat(args)))() 15 | : callServer(id, args); 16 | } 17 | var id = metaData.id, 18 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 19 | index 7a5db2b..74dbad0 100644 20 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 21 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.production.js 22 | @@ -512,9 +512,9 @@ function createBoundServerReference(metaData, callServer) { 23 | return bound 24 | ? "fulfilled" === bound.status 25 | ? callServer(id, bound.value.concat(args)) 26 | - : Promise.resolve(bound).then(function (boundArgs) { 27 | - return callServer(id, boundArgs.concat(args)); 28 | - }) 29 | + // HACK: This is required to make native server actions return a non-undefined value. 30 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web. 31 | + : (async () => callServer(id, (await bound).concat(args)))() 32 | : callServer(id, args); 33 | } 34 | var id = metaData.id, 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "torus-dom", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo run:android", 8 | "ios": "expo run:ios", 9 | "web": "expo start --web", 10 | "lint": "expo lint", 11 | "deploy:ios": "npx testflight", 12 | "deploy:web": "expo export -p web && npx eas-cli@latest deploy" 13 | }, 14 | "dependencies": { 15 | "@bacons/apple-colors": "^0.0.8", 16 | "@expo-google-fonts/inter": "^0.3.0", 17 | "@expo-google-fonts/roboto-mono": "^0.3.0", 18 | "@expo-google-fonts/source-code-pro": "^0.3.0", 19 | "@expo/vector-icons": "^14.1.0", 20 | "@react-native-masked-view/masked-view": "0.3.2", 21 | "@react-native-segmented-control/segmented-control": "2.5.7", 22 | "@svgr/core": "^8.1.0", 23 | "@svgr/plugin-jsx": "^8.1.0", 24 | "@svgr/plugin-svgo": "^8.1.0", 25 | "babel-plugin-react-compiler": "^19.0.0-beta-af1b7da-20250417", 26 | "eslint-plugin-react-compiler": "^19.1.0-rc.1", 27 | "expo": "^53", 28 | "expo-application": "~6.1.4", 29 | "expo-blur": "~14.1.4", 30 | "expo-clipboard": "~7.1.4", 31 | "expo-constants": "~17.1.5", 32 | "expo-font": "~13.3.1", 33 | "expo-haptics": "~14.1.4", 34 | "expo-image": "~2.1.6", 35 | "expo-linking": "~7.1.4", 36 | "expo-router": "~5.0.5", 37 | "expo-splash-screen": "~0.30.8", 38 | "expo-sqlite": "~15.2.9", 39 | "expo-status-bar": "~2.2.3", 40 | "expo-symbols": "~0.4.4", 41 | "expo-system-ui": "~5.0.7", 42 | "expo-updates": "~0.28.12", 43 | "expo-web-browser": "~14.1.6", 44 | "react": "19.0.0", 45 | "react-dom": "19.0.0", 46 | "react-native": "0.79.2", 47 | "react-native-gesture-handler": "~2.24.0", 48 | "react-native-reanimated": "~3.17.3", 49 | "react-native-safe-area-context": "5.4.0", 50 | "react-native-screens": "~4.10.0", 51 | "react-native-svg": "15.11.2", 52 | "react-native-web": "~0.20.0", 53 | "react-native-webview": "13.13.5", 54 | "three": "^0.176.0", 55 | "vaul": "^1.1.2" 56 | }, 57 | "devDependencies": { 58 | "@babel/core": "^7.25.2", 59 | "@types/react": "~19.0.10", 60 | "eslint": "^9.0.0", 61 | "eslint-config-expo": "~9.2.0", 62 | "expo-atlas": "^0.4.0", 63 | "typescript": "^5.3.3" 64 | }, 65 | "private": true 66 | } 67 | -------------------------------------------------------------------------------- /src/components/ui/Stack.tsx: -------------------------------------------------------------------------------- 1 | // import { Stack as NativeStack } from "expo-router"; 2 | import { NativeStackNavigationOptions } from "@react-navigation/native-stack"; 3 | import React from "react"; 4 | 5 | // Better transitions on web, no changes on native. 6 | import NativeStack from "@/components/layout/modalNavigator"; 7 | 8 | // These are the default stack options for iOS, they disable on other platforms. 9 | const DEFAULT_STACK_HEADER: NativeStackNavigationOptions = 10 | process.env.EXPO_OS !== "ios" 11 | ? {} 12 | : { 13 | headerTransparent: true, 14 | headerBlurEffect: "systemChromeMaterial", 15 | headerShadowVisible: true, 16 | headerLargeTitleShadowVisible: false, 17 | headerLargeStyle: { 18 | backgroundColor: "transparent", 19 | }, 20 | headerLargeTitle: true, 21 | }; 22 | 23 | /** Create a bottom sheet on iOS with extra snap points (`sheetAllowedDetents`) */ 24 | export const BOTTOM_SHEET: NativeStackNavigationOptions = { 25 | // https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md#sheetalloweddetents 26 | presentation: "formSheet", 27 | gestureDirection: "vertical", 28 | animation: "slide_from_bottom", 29 | sheetGrabberVisible: true, 30 | sheetInitialDetentIndex: 0, 31 | sheetAllowedDetents: [0.5, 1.0], 32 | }; 33 | 34 | export default function Stack({ 35 | screenOptions, 36 | children, 37 | ...props 38 | }: React.ComponentProps) { 39 | const processedChildren = React.Children.map(children, (child) => { 40 | if (React.isValidElement(child)) { 41 | const { sheet, modal, ...props } = child.props; 42 | if (sheet) { 43 | return React.cloneElement(child, { 44 | ...props, 45 | options: { 46 | ...BOTTOM_SHEET, 47 | ...props.options, 48 | }, 49 | }); 50 | } else if (modal) { 51 | return React.cloneElement(child, { 52 | ...props, 53 | options: { 54 | presentation: "modal", 55 | ...props.options, 56 | }, 57 | }); 58 | } 59 | } 60 | return child; 61 | }); 62 | 63 | return ( 64 | 72 | ); 73 | } 74 | 75 | Stack.Screen = NativeStack.Screen as React.FC< 76 | React.ComponentProps & { 77 | /** Make the sheet open as a bottom sheet with default options on iOS. */ 78 | sheet?: boolean; 79 | /** Make the screen open as a modal. */ 80 | modal?: boolean; 81 | } 82 | >; 83 | -------------------------------------------------------------------------------- /src/components/ui/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import { IconSymbol, IconSymbolName } from "@/components/ui/IconSymbol"; 2 | import { 3 | BottomTabBarButtonProps, 4 | BottomTabNavigationOptions, 5 | } from "@react-navigation/bottom-tabs"; 6 | import * as Haptics from "expo-haptics"; 7 | import React from "react"; 8 | // Better transitions on web, no changes on native. 9 | import { PlatformPressable } from "@react-navigation/elements"; 10 | import { Tabs as NativeTabs } from "expo-router"; 11 | import BlurTabBarBackground from "./TabBarBackground"; 12 | 13 | // These are the default tab options for iOS, they disable on other platforms. 14 | const DEFAULT_TABS: BottomTabNavigationOptions = 15 | process.env.EXPO_OS !== "ios" 16 | ? { 17 | headerShown: false, 18 | } 19 | : { 20 | headerShown: false, 21 | tabBarButton: HapticTab, 22 | tabBarBackground: BlurTabBarBackground, 23 | tabBarStyle: { 24 | // Use a transparent background on iOS to show the blur effect 25 | position: "absolute", 26 | }, 27 | }; 28 | 29 | export default function Tabs({ 30 | screenOptions, 31 | children, 32 | ...props 33 | }: React.ComponentProps) { 34 | const processedChildren = React.Children.map(children, (child) => { 35 | if (React.isValidElement(child)) { 36 | const { systemImage, title, ...props } = child.props; 37 | if (systemImage || title != null) { 38 | return React.cloneElement(child, { 39 | ...props, 40 | options: { 41 | tabBarIcon: !systemImage 42 | ? undefined 43 | : (props: any) => , 44 | title, 45 | ...props.options, 46 | }, 47 | }); 48 | } 49 | } 50 | return child; 51 | }); 52 | 53 | return ( 54 | 62 | ); 63 | } 64 | 65 | Tabs.Screen = NativeTabs.Screen as React.FC< 66 | React.ComponentProps & { 67 | /** Add a system image for the tab icon. */ 68 | systemImage?: IconSymbolName; 69 | /** Set the title of the icon. */ 70 | title?: string; 71 | } 72 | >; 73 | 74 | function HapticTab(props: BottomTabBarButtonProps) { 75 | return ( 76 | { 79 | if (process.env.EXPO_OS === "ios") { 80 | // Add a soft haptic feedback when pressing down on the tabs. 81 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 82 | } 83 | props.onPressIn?.(ev); 84 | }} 85 | /> 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/components/runtime/local-storage.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "expo-sqlite/kv-store"; 2 | 3 | // localStorage polyfill. Life's too short to not have some storage API. 4 | if (typeof localStorage === "undefined") { 5 | class StoragePolyfill { 6 | /** 7 | * Returns the number of key/value pairs. 8 | * 9 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/length) 10 | */ 11 | get length(): number { 12 | return Storage.getAllKeysSync().length; 13 | } 14 | /** 15 | * Removes all key/value pairs, if there are any. 16 | * 17 | * Dispatches a storage event on Window objects holding an equivalent Storage object. 18 | * 19 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/clear) 20 | */ 21 | clear(): void { 22 | Storage.clearSync(); 23 | } 24 | /** 25 | * Returns the current value associated with the given key, or null if the given key does not exist. 26 | * 27 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem) 28 | */ 29 | getItem(key: string): string | null { 30 | return Storage.getItemSync(key) ?? null; 31 | } 32 | /** 33 | * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs. 34 | * 35 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/key) 36 | */ 37 | key(index: number): string | null { 38 | return Storage.getAllKeysSync()[index] ?? null; 39 | } 40 | /** 41 | * Removes the key/value pair with the given key, if a key/value pair with the given key exists. 42 | * 43 | * Dispatches a storage event on Window objects holding an equivalent Storage object. 44 | * 45 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/removeItem) 46 | */ 47 | removeItem(key: string): void { 48 | Storage.removeItemSync(key); 49 | } 50 | /** 51 | * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously. 52 | * 53 | * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.) 54 | * 55 | * Dispatches a storage event on Window objects holding an equivalent Storage object. 56 | * 57 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem) 58 | */ 59 | setItem(key: string, value: string): void { 60 | Storage.setItemSync(key, value); 61 | } 62 | // [name: string]: any; 63 | } 64 | 65 | const localStoragePolyfill = new StoragePolyfill(); 66 | 67 | Object.defineProperty(global, "localStorage", { 68 | value: localStoragePolyfill, 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/runtime/apple-css-variables.ts: -------------------------------------------------------------------------------- 1 | // Polyfill to add support for Apple CSS variables for colors in React Native 2 | 3 | import { LogBox, StyleSheet } from "react-native"; 4 | 5 | // @ts-expect-error 6 | import ReactNativeStyleAttributes from "react-native/Libraries/Components/View/ReactNativeStyleAttributes"; 7 | 8 | import * as AC from "@bacons/apple-colors"; 9 | 10 | const CSS_VARIABLES: Record = { 11 | "--apple-blue": AC.systemBlue, 12 | "--apple-brown": AC.systemBrown, 13 | "--apple-cyan": AC.systemCyan, 14 | "--apple-green": AC.systemGreen, 15 | "--apple-indigo": AC.systemIndigo, 16 | "--apple-mint": AC.systemMint, 17 | "--apple-orange": AC.systemOrange, 18 | "--apple-pink": AC.systemPink, 19 | "--apple-purple": AC.systemPurple, 20 | "--apple-red": AC.systemRed, 21 | "--apple-teal": AC.systemTeal, 22 | "--apple-yellow": AC.systemYellow, 23 | "--apple-gray": AC.systemGray, 24 | "--apple-gray2": AC.systemGray2, 25 | "--apple-gray3": AC.systemGray3, 26 | "--apple-gray4": AC.systemGray4, 27 | "--apple-gray5": AC.systemGray5, 28 | "--apple-gray6": AC.systemGray6, 29 | "--apple-label": AC.label, 30 | "--apple-secondary-label": AC.secondaryLabel, 31 | "--apple-tertiary-label": AC.tertiaryLabel, 32 | "--apple-quaternary-label": AC.quaternaryLabel, 33 | "--apple-fill": AC.systemFill, 34 | "--apple-secondary-fill": AC.secondarySystemFill, 35 | "--apple-tertiary-fill": AC.tertiarySystemFill, 36 | "--apple-quaternary-fill": AC.quaternarySystemFill, 37 | "--apple-placeholder-text": AC.placeholderText, 38 | "--apple-background": AC.systemBackground, 39 | "--apple-secondary-background": AC.secondarySystemBackground, 40 | "--apple-tertiary-background": AC.tertiarySystemBackground, 41 | "--apple-grouped-background": AC.systemGroupedBackground, 42 | "--apple-secondary-grouped-background": AC.secondarySystemGroupedBackground, 43 | "--apple-tertiary-grouped-background": AC.tertiarySystemGroupedBackground, 44 | "--apple-separator": AC.separator, 45 | "--apple-opaque-separator": AC.opaqueSeparator, 46 | "--apple-link": AC.link, 47 | "--apple-dark-text": AC.darkText, 48 | "--apple-light-text": AC.lightText, 49 | }; 50 | 51 | function customColorProcessor(processor: (value: any) => any, value: any) { 52 | if (typeof value === "string") { 53 | const cssVariable = value.match(/var\((.*)\)/)?.[1]; 54 | if (cssVariable) { 55 | const cssValue = CSS_VARIABLES[cssVariable]; 56 | if (cssValue) { 57 | return processor(cssValue); 58 | } else { 59 | console.warn(`CSS variable ${cssVariable} not found`); 60 | } 61 | } 62 | } 63 | return processor(value); 64 | } 65 | 66 | LogBox.ignoreLogs([/Overwriting (\w+) style attribute preprocessor/]); 67 | 68 | const colorAttr = ReactNativeStyleAttributes.color; 69 | for (const [key, value] of Object.entries(ReactNativeStyleAttributes)) { 70 | if (value === colorAttr) { 71 | StyleSheet.setStyleAttributePreprocessor( 72 | key, 73 | // @ts-expect-error 74 | customColorProcessor.bind(null, value.process) 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/app/(index,info)/info.tsx: -------------------------------------------------------------------------------- 1 | import * as Form from "@/components/ui/Form"; 2 | import Stack from "@/components/ui/Stack"; 3 | import * as AC from "@bacons/apple-colors"; 4 | import { Link } from "expo-router"; 5 | import { Text, View } from "react-native"; 6 | import Animated, { 7 | interpolate, 8 | useAnimatedRef, 9 | useAnimatedStyle, 10 | useScrollViewOffset, 11 | } from "react-native-reanimated"; 12 | 13 | export default function Page() { 14 | const ref = useAnimatedRef(); 15 | const scroll = useScrollViewOffset(ref); 16 | const style = useAnimatedStyle(() => ({ 17 | transform: [ 18 | { translateY: interpolate(scroll.value, [-120, -70], [50, 0], "clamp") }, 19 | ], 20 | })); 21 | 22 | return ( 23 | 24 | {process.env.EXPO_OS !== "web" && ( 25 | ( 28 | 35 | 36 | 43 | Bottom Sheet 44 | 45 | 46 | 47 | ), 48 | headerTitle() { 49 | return <>; 50 | }, 51 | }} 52 | /> 53 | )} 54 | 55 | 59 | Help improve Search by allowing Apple to store the searches you 60 | enter into Safari, Siri, and Spotlight in a way that is not linked 61 | to you.{"\n\n"}Searches include lookups of general knowledge, and 62 | requests to do things like play music and get directions.{"\n"} 63 | 64 | About Search & Privacy... 65 | 66 | 67 | } 68 | > 69 | Default 70 | Hint 71 | { 73 | console.log("Hey"); 74 | }} 75 | > 76 | Pressable 77 | 78 | 79 | 80 | Custom style 81 | 82 | Bold 83 | 84 | 85 | Wrapped 86 | 87 | 88 | {/* Table style: | A B |*/} 89 | 90 | Foo 91 | 92 | Bar 93 | 94 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/components/ui/ContentUnavailable.tsx: -------------------------------------------------------------------------------- 1 | // Similar to https://developer.apple.com/documentation/swiftui/contentunavailableview 2 | 3 | import { View, Text } from "react-native"; 4 | import { IconSymbol, IconSymbolName } from "./IconSymbol"; 5 | import * as AC from "@bacons/apple-colors"; 6 | 7 | type Props = { 8 | title: string; 9 | description?: string; 10 | systemImage: IconSymbolName | (React.ReactElement & {}); 11 | actions?: React.ReactNode; 12 | }; 13 | 14 | export function ContentUnavailable({ 15 | title, 16 | description, 17 | systemImage, 18 | actions, 19 | ...props 20 | }: 21 | | Props 22 | | ({ 23 | search: boolean | string; 24 | } & Partial) 25 | | ({ 26 | internet: boolean; 27 | } & Partial)) { 28 | let resolvedTitle = title; 29 | let resolvedSystemImage = systemImage; 30 | let resolvedDescription = description; 31 | let animationSpec: 32 | | import("expo-symbols").SymbolViewProps["animationSpec"] 33 | | undefined; 34 | 35 | if ("search" in props && props.search) { 36 | resolvedTitle = 37 | title ?? typeof props.search === "string" 38 | ? `No Results for "${props.search}"` 39 | : `No Results`; 40 | resolvedSystemImage ??= "magnifyingglass"; 41 | resolvedDescription ??= `Check the spelling or try a new search.`; 42 | } else if ("internet" in props && props.internet) { 43 | resolvedTitle ??= "Connection issue"; 44 | resolvedSystemImage ??= 45 | process.env.EXPO_OS === "ios" ? "wifi" : "wifi.slash"; 46 | 47 | animationSpec = { 48 | repeating: true, 49 | variableAnimationSpec: { 50 | iterative: true, 51 | dimInactiveLayers: true, 52 | }, 53 | }; 54 | resolvedDescription ??= `Check your internet connection.`; 55 | } 56 | 57 | return ( 58 | 66 | {typeof resolvedSystemImage === "string" ? ( 67 | 73 | ) : ( 74 | resolvedSystemImage 75 | )} 76 | 77 | 84 | 93 | {resolvedTitle} 94 | 95 | {resolvedDescription && ( 96 | 104 | {resolvedDescription} 105 | 106 | )} 107 | 108 | {actions} 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /src/app/(index,info)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import Stack from "@/components/ui/Stack"; 2 | import * as AC from "@bacons/apple-colors"; 3 | import { Text, View } from "react-native"; 4 | 5 | import * as Form from "@/components/ui/Form"; 6 | import { IconSymbol } from "@/components/ui/IconSymbol"; 7 | import { useMemo } from "react"; 8 | 9 | export const unstable_settings = { 10 | index: { 11 | initialRouteName: "index", 12 | }, 13 | info: { 14 | initialRouteName: "info", 15 | }, 16 | }; 17 | 18 | export default function Layout({ segment }: { segment: string }) { 19 | const screenName = segment.match(/\((.*)\)/)?.[1]!; 20 | 21 | const firstScreen = useMemo(() => { 22 | if (screenName === "index") { 23 | return ( 24 | ( 28 | 29 | 30 | 31 | ), 32 | }} 33 | /> 34 | ); 35 | } else { 36 | return ; 37 | } 38 | }, [screenName]); 39 | 40 | return ( 41 | 42 | {firstScreen} 43 | 44 | ( 54 | 55 | 60 | 61 | ), 62 | }} 63 | /> 64 | 65 | ( 70 | 71 | Done 72 | 73 | ), 74 | }} 75 | /> 76 | 77 | ( 82 | 83 | Done 84 | 85 | ), 86 | }} 87 | /> 88 | ( 93 | 94 | Done 95 | 96 | ), 97 | }} 98 | /> 99 | 100 | ); 101 | } 102 | 103 | function Avatar() { 104 | return ( 105 | 117 | 126 | EB 127 | 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/app/(index,info)/icon.tsx: -------------------------------------------------------------------------------- 1 | import Stack from "@/components/ui/Stack"; 2 | import TouchableBounce from "@/components/ui/TouchableBounce"; 3 | import { ScrollView, View } from "react-native"; 4 | import { Image } from "expo-image"; 5 | import * as AC from "@bacons/apple-colors"; 6 | import MaskedView from "@react-native-masked-view/masked-view"; 7 | 8 | const backgroundImage = 9 | process.env.EXPO_OS === "web" 10 | ? `backgroundImage` 11 | : `experimental_backgroundImage`; 12 | 13 | export default function Page() { 14 | const icons = [ 15 | "https://github.com/expo.png", 16 | "https://github.com/apple.png", 17 | "https://github.com/facebook.png", 18 | "https://github.com/evanbacon.png", 19 | "https://github.com/kitten.png", 20 | ]; 21 | return ( 22 | <> 23 | 24 | 25 | {icons.map((icon) => ( 26 | {}}> 27 | 35 | 44 | 45 | 46 | {process.env.EXPO_OS === "web" ? ( 47 | 62 | ) : ( 63 | 81 | } 82 | > 83 | 94 | 95 | )} 96 | 97 | ))} 98 | 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/components/ui/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { 5 | Animated, 6 | Easing, 7 | StyleProp, 8 | StyleSheet, 9 | View, 10 | ViewStyle, 11 | useColorScheme, 12 | } from "react-native"; 13 | 14 | const BASE_COLORS = { 15 | dark: { primary: "rgb(17, 17, 17)", secondary: "rgb(51, 51, 51)" }, 16 | light: { 17 | primary: "rgb(250, 250, 250)", 18 | secondary: "rgb(205, 205, 205)", 19 | }, 20 | } as const; 21 | 22 | const makeColors = (mode: keyof typeof BASE_COLORS) => [ 23 | BASE_COLORS[mode].primary, 24 | BASE_COLORS[mode].secondary, 25 | BASE_COLORS[mode].secondary, 26 | BASE_COLORS[mode].primary, 27 | BASE_COLORS[mode].secondary, 28 | BASE_COLORS[mode].primary, 29 | ]; 30 | 31 | const DARK_COLORS = new Array(3) 32 | .fill(0) 33 | .map(() => makeColors("dark")) 34 | .flat(); 35 | 36 | const LIGHT_COLORS = new Array(3) 37 | .fill(0) 38 | .map(() => makeColors("light")) 39 | .flat(); 40 | 41 | export const SkeletonBox = ({ 42 | width, 43 | height, 44 | borderRadius = 8, 45 | delay, 46 | }: { 47 | width: number; 48 | height: number; 49 | borderRadius?: number; 50 | delay?: number; 51 | }) => { 52 | return ( 53 | 65 | ); 66 | }; 67 | 68 | const Skeleton = ({ 69 | style, 70 | delay, 71 | dark: inputDark, 72 | }: { 73 | style?: StyleProp; 74 | delay?: number; 75 | dark?: boolean; 76 | } = {}) => { 77 | // eslint-disable-next-line react-hooks/rules-of-hooks 78 | const dark = inputDark ?? useColorScheme() !== "light"; 79 | const translateX = React.useRef(new Animated.Value(-1)).current; 80 | const [width, setWidth] = React.useState(150); 81 | 82 | const colors = dark ? DARK_COLORS : LIGHT_COLORS; 83 | const targetRef = React.useRef(null); 84 | 85 | React.useEffect(() => { 86 | const anim = Animated.loop( 87 | Animated.sequence([ 88 | Animated.timing(translateX, { 89 | delay: delay || 0, 90 | toValue: 1, 91 | duration: 5000, 92 | useNativeDriver: process.env.EXPO_OS !== "web", 93 | // Ease in 94 | easing: Easing.in(Easing.ease), 95 | }), 96 | ]) 97 | ); 98 | anim.start(); 99 | return () => { 100 | anim.stop(); 101 | }; 102 | }, [translateX, delay]); 103 | 104 | return ( 105 | { 118 | targetRef.current?.measureInWindow((_x, _y, width, _height) => { 119 | setWidth(width); 120 | }); 121 | }} 122 | > 123 | 138 | 150 | 151 | 152 | ); 153 | }; 154 | 155 | export default Skeleton; 156 | -------------------------------------------------------------------------------- /src/app/(index,info)/account.tsx: -------------------------------------------------------------------------------- 1 | import * as Form from "@/components/ui/Form"; 2 | import { IconSymbol } from "@/components/ui/IconSymbol"; 3 | import * as AC from "@bacons/apple-colors"; 4 | import { Image, Platform, View } from "react-native"; 5 | import * as Application from "expo-application"; 6 | 7 | export default function Page() { 8 | return ( 9 | 10 | 11 | 12 | 20 | 21 | Evan's world 22 | Today 23 | 24 | 25 | 30 | Game Center 31 | 32 | 33 | 34 | 35 | Apps 36 | Subscriptions 37 | Purchase History 38 | Notifications 39 | 40 | 41 | 42 | {}}> 43 | Redeem Gift Card or Code 44 | 45 | {}}> 46 | Send Gift Card by Email 47 | 48 | {}}> 49 | Add Money to Account 50 | 51 | 52 | 53 | 54 | Personalized Recommendations 55 | 56 | 57 | 58 | Update All 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | function AppUpdate({ name, icon }: { name: string; icon: string }) { 71 | return ( 72 | 73 | 74 | 82 | 83 | {name} 84 | Today 85 | 86 | 87 | 88 | 89 | 95 | 96 | - Minor bug-fixes 97 | 98 | ); 99 | } 100 | 101 | function SettingsInfoFooter() { 102 | const name = `${Application.applicationName} for ${Platform.select({ 103 | web: "Web", 104 | ios: `iOS v${Application.nativeApplicationVersion} (${Application.nativeBuildVersion})`, 105 | android: `Android v${Application.nativeApplicationVersion} (${Application.nativeBuildVersion})`, 106 | })}`; 107 | return ( 108 | 111 | 118 | {name} 119 | 120 | 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/Segments.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import SegmentedControl from "@react-native-segmented-control/segmented-control"; 4 | 5 | import React, { 6 | Children, 7 | createContext, 8 | ReactNode, 9 | use, 10 | useState, 11 | } from "react"; 12 | import { StyleProp, ViewStyle } from "react-native"; 13 | 14 | /* ---------------------------------------------------------------------------------- 15 | * Context 16 | * ----------------------------------------------------------------------------------*/ 17 | 18 | interface SegmentsContextValue { 19 | value: string; 20 | setValue: React.Dispatch>; 21 | } 22 | 23 | const SegmentsContext = createContext( 24 | undefined 25 | ); 26 | 27 | /* ---------------------------------------------------------------------------------- 28 | * Segments (Container) 29 | * ----------------------------------------------------------------------------------*/ 30 | 31 | interface SegmentsProps { 32 | /** The initial value for the controlled Segments */ 33 | defaultValue: string; 34 | 35 | /** The children of the Segments component (SegmentsList, SegmentsContent, etc.) */ 36 | children: ReactNode; 37 | } 38 | 39 | export function Segments({ defaultValue, children }: SegmentsProps) { 40 | const [value, setValue] = useState(defaultValue); 41 | 42 | return ( 43 | {children} 44 | ); 45 | } 46 | 47 | export function SegmentsList({ 48 | children, 49 | style, 50 | }: { 51 | /** The children will typically be one or more SegmentsTrigger elements */ 52 | children: ReactNode; 53 | style?: StyleProp; 54 | }) { 55 | const context = use(SegmentsContext); 56 | if (!context) { 57 | throw new Error("SegmentsList must be used within a Segments"); 58 | } 59 | 60 | const { value, setValue } = context; 61 | 62 | // Filter out only SegmentsTrigger elements 63 | const triggers = Children.toArray(children).filter( 64 | (child: any) => child.type?.displayName === "SegmentsTrigger" 65 | ); 66 | 67 | // Collect labels and values from each SegmentsTrigger 68 | const labels = triggers.map((trigger: any) => trigger.props.children); 69 | const values = triggers.map((trigger: any) => trigger.props.value); 70 | 71 | // When the user switches the segment, update the context value 72 | const handleChange = (event: any) => { 73 | const index = event.nativeEvent.selectedSegmentIndex; 74 | setValue(values[index]); 75 | }; 76 | 77 | return ( 78 | 84 | ); 85 | } 86 | 87 | /* ---------------------------------------------------------------------------------- 88 | * SegmentsTrigger 89 | * ----------------------------------------------------------------------------------*/ 90 | 91 | interface SegmentsTriggerProps { 92 | /** The value that this trigger represents */ 93 | value: string; 94 | /** The label to display for this trigger in the SegmentedControl */ 95 | children: ReactNode; 96 | } 97 | 98 | export function SegmentsTrigger(_: SegmentsTriggerProps) { 99 | // We don't actually render anything here. This component serves as a "marker" 100 | // for the SegmentsList to know about possible segments. 101 | return null; 102 | } 103 | 104 | SegmentsTrigger.displayName = "SegmentsTrigger"; 105 | /* ---------------------------------------------------------------------------------- 106 | * SegmentsContent 107 | * ----------------------------------------------------------------------------------*/ 108 | 109 | interface SegmentsContentProps { 110 | /** The value from the matching SegmentsTrigger */ 111 | value: string; 112 | /** The content to be rendered when the active value matches */ 113 | children: ReactNode; 114 | } 115 | 116 | export function SegmentsContent({ value, children }: SegmentsContentProps) { 117 | const context = use(SegmentsContext); 118 | if (!context) { 119 | throw new Error("SegmentsContent must be used within a Segments"); 120 | } 121 | 122 | const { value: currentValue } = context; 123 | if (currentValue !== value) { 124 | return null; 125 | } 126 | 127 | if (process.env.EXPO_OS === "web") { 128 | return <>{children}; 129 | } 130 | 131 | return <>{children}; 132 | } 133 | 134 | Segments.List = SegmentsList; 135 | Segments.Trigger = SegmentsTrigger; 136 | Segments.Content = SegmentsContent; 137 | -------------------------------------------------------------------------------- /src/components/example/privacy-dom.tsx: -------------------------------------------------------------------------------- 1 | "use dom"; 2 | 3 | import React from "react"; 4 | 5 | const PrivacyPolicy = (_: { dom?: import("expo/dom").DOMProps }) => { 6 | return ( 7 |
14 |

15 | Welcome to our Privacy Policy page! When you use our web site services, 16 | you trust us with your information. This Privacy Policy is meant to help 17 | you understand what data we collect, why we collect it, and what we do 18 | with it. This is important; we hope you will take time to read it 19 | carefully. 20 |

21 |

Information We Collect

22 |

23 | We collect information to provide better services to all our users. We 24 | collect information in the following ways: 25 |

26 |
    27 |
  • 28 | Information you give us. For example, our services require you to sign 29 | up for an account. When you do, we’ll ask for personal information, 30 | like your name, email address, telephone number or credit card. 31 |
  • 32 |
  • 33 | Information we get from your use of our services. We collect 34 | information about the services that you use and how you use them, like 35 | when you visit a website that uses our advertising services or you 36 | view and interact with our ads and content. 37 |
  • 38 |
39 |

How We Use Information We Collect

40 |

41 | We use the information we collect from all of our services to provide, 42 | maintain, protect and improve them, to develop new ones, and to protect 43 | our users. We also use this information to offer you tailored content – 44 | like giving you more relevant search results and ads. 45 |

46 |

Transparency and Choice

47 |

48 | People have different privacy concerns. Our goal is to be clear about 49 | what information we collect, so that you can make meaningful choices 50 | about how it is used. 51 |

52 |

Information You Share

53 |

54 | Many of our services let you share information with others. Remember 55 | that when you share information publicly, it may be indexable by search 56 | engines. 57 |

58 |

59 | Accessing and Updating Your Personal Information 60 |

61 |

62 | Whenever you use our services, we aim to provide you with access to your 63 | personal information. If that information is wrong, we strive to give 64 | you ways to update it quickly or to delete it – unless we have to keep 65 | that information for legitimate business or legal purposes. 66 |

67 |

Information We Share

68 |

69 | We do not share personal information with companies, organizations and 70 | individuals outside of our company unless one of the following 71 | circumstances applies: 72 |

73 |
    74 |
  • With your consent
  • 75 |
  • For external processing
  • 76 |
  • For legal reasons
  • 77 |
78 |

Security of Information

79 |

80 | We work hard to protect our users from unauthorized access to or 81 | unauthorized alteration, disclosure or destruction of information we 82 | hold. 83 |

84 |

When This Privacy Policy Applies

85 |

86 | Our Privacy Policy applies to all of the services offered by our company 87 | and its affiliates, including services offered on other sites (such as 88 | our advertising services), but excludes services that have separate 89 | privacy policies that do not incorporate this Privacy Policy. 90 |

91 |

92 | Compliance and Cooperation with Regulatory Authorities 93 |

94 |

95 | We regularly review our compliance with our Privacy Policy. We also 96 | adhere to several self-regulatory frameworks. 97 |

98 |

Changes

99 |

100 | Our Privacy Policy may change from time to time. We will not reduce your 101 | rights under this Privacy Policy without your explicit consent. We will 102 | post any privacy policy changes on this page and, if the changes are 103 | significant, we will provide a more prominent notice (including, for 104 | certain services, email notification of privacy policy changes). 105 |

106 |
107 | ); 108 | }; 109 | 110 | export default PrivacyPolicy; 111 | -------------------------------------------------------------------------------- /src/hooks/useTabToTop.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventArg, 3 | NavigationProp, 4 | useNavigation, 5 | useRoute, 6 | } from "@react-navigation/core"; 7 | import * as React from "react"; 8 | import type { ScrollView } from "react-native"; 9 | import type { WebView } from "react-native-webview"; 10 | 11 | type ScrollOptions = { x?: number; y?: number; animated?: boolean }; 12 | 13 | type ScrollableView = 14 | | { scrollToTop(): void } 15 | | { scrollTo(options: ScrollOptions): void } 16 | | { scrollToOffset(options: { offset?: number; animated?: boolean }): void } 17 | | { scrollResponderScrollTo(options: ScrollOptions): void }; 18 | 19 | type ScrollableWrapper = 20 | | { getScrollResponder(): React.ReactNode | ScrollView } 21 | | { getNode(): ScrollableView } 22 | | ScrollableView; 23 | 24 | function getScrollableNode( 25 | ref: React.RefObject | React.RefObject 26 | ) { 27 | if (ref?.current == null) { 28 | return null; 29 | } 30 | 31 | if ( 32 | "scrollToTop" in ref.current || 33 | "scrollTo" in ref.current || 34 | "scrollToOffset" in ref.current || 35 | "scrollResponderScrollTo" in ref.current 36 | ) { 37 | // This is already a scrollable node. 38 | return ref.current; 39 | } else if ("getScrollResponder" in ref.current) { 40 | // If the view is a wrapper like FlatList, SectionList etc. 41 | // We need to use `getScrollResponder` to get access to the scroll responder 42 | return ref.current.getScrollResponder(); 43 | } else if ("getNode" in ref.current) { 44 | // When a `ScrollView` is wraped in `Animated.createAnimatedComponent` 45 | // we need to use `getNode` to get the ref to the actual scrollview. 46 | // Note that `getNode` is deprecated in newer versions of react-native 47 | // this is why we check if we already have a scrollable node above. 48 | return ref.current.getNode(); 49 | } else { 50 | return ref.current; 51 | } 52 | } 53 | 54 | export function useScrollToTop( 55 | ref: 56 | | React.RefObject 57 | | React.RefObject 58 | | React.Ref, 59 | offset: number = 0 60 | ) { 61 | const navigation = useNavigation(); 62 | const route = useRoute(); 63 | 64 | React.useEffect(() => { 65 | let tabNavigations: NavigationProp[] = []; 66 | let currentNavigation = navigation; 67 | 68 | // If the screen is nested inside multiple tab navigators, we should scroll to top for any of them 69 | // So we need to find all the parent tab navigators and add the listeners there 70 | while (currentNavigation) { 71 | if (currentNavigation.getState()?.type === "tab") { 72 | tabNavigations.push(currentNavigation); 73 | } 74 | 75 | currentNavigation = currentNavigation.getParent(); 76 | } 77 | 78 | if (tabNavigations.length === 0) { 79 | return; 80 | } 81 | 82 | const unsubscribers = tabNavigations.map((tab) => { 83 | return tab.addListener( 84 | // We don't wanna import tab types here to avoid extra deps 85 | // in addition, there are multiple tab implementations 86 | // @ts-expect-error 87 | "tabPress", 88 | (e: EventArg<"tabPress", true>) => { 89 | // We should scroll to top only when the screen is focused 90 | const isFocused = navigation.isFocused(); 91 | 92 | // In a nested stack navigator, tab press resets the stack to first screen 93 | // So we should scroll to top only when we are on first screen 94 | const isFirst = 95 | tabNavigations.includes(navigation) || 96 | navigation.getState()?.routes[0].key === route.key; 97 | 98 | // Run the operation in the next frame so we're sure all listeners have been run 99 | // This is necessary to know if preventDefault() has been called 100 | requestAnimationFrame(() => { 101 | const scrollable = getScrollableNode(ref) as 102 | | ScrollableWrapper 103 | | WebView; 104 | 105 | if (isFocused && isFirst && scrollable && !e.defaultPrevented) { 106 | if ("scrollToTop" in scrollable) { 107 | scrollable.scrollToTop(); 108 | } else if ("scrollTo" in scrollable) { 109 | scrollable.scrollTo({ y: offset, animated: true }); 110 | } else if ("scrollToOffset" in scrollable) { 111 | scrollable.scrollToOffset({ offset: offset, animated: true }); 112 | } else if ("scrollResponderScrollTo" in scrollable) { 113 | scrollable.scrollResponderScrollTo({ 114 | y: offset, 115 | animated: true, 116 | }); 117 | } else if ("injectJavaScript" in scrollable) { 118 | scrollable.injectJavaScript( 119 | `;window.scrollTo({ top: ${offset}, behavior: 'smooth' }); true;` 120 | ); 121 | } 122 | } 123 | }); 124 | } 125 | ); 126 | }); 127 | 128 | return () => { 129 | unsubscribers.forEach((unsubscribe) => unsubscribe()); 130 | }; 131 | }, [navigation, ref, offset, route.key]); 132 | } 133 | 134 | export const useScrollRef = 135 | process.env.EXPO_OS === "web" 136 | ? () => undefined 137 | : () => { 138 | const ref = React.useRef(null); 139 | 140 | useScrollToTop(ref); 141 | 142 | return ref; 143 | }; 144 | -------------------------------------------------------------------------------- /src/components/layout/modalNavigator.web.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createNavigatorFactory, 3 | DefaultRouterOptions, 4 | ParamListBase, 5 | StackNavigationState, 6 | StackRouter, 7 | useNavigationBuilder, 8 | } from "@react-navigation/native"; 9 | import { 10 | NativeStackNavigationOptions, 11 | NativeStackView, 12 | } from "@react-navigation/native-stack"; 13 | import { withLayoutContext } from "expo-router"; 14 | import React from "react"; 15 | import { Platform } from "react-native"; 16 | import { Drawer } from "vaul"; 17 | 18 | import modalStyles from "./modal.module.css"; 19 | 20 | import * as AC from "@bacons/apple-colors"; 21 | 22 | /** Extend NativeStackNavigationOptions with extra sheet/detent props */ 23 | type MyModalStackNavigationOptions = NativeStackNavigationOptions & { 24 | presentation?: 25 | | "modal" 26 | | "formSheet" 27 | | "containedModal" 28 | | "card" 29 | | "fullScreenModal"; 30 | /** 31 | * If you want to mimic iOS sheet detents on native (iOS 16+ w/ react-native-screens), 32 | * you might do something like: 33 | * 34 | * supportedOrientations?: string[]; 35 | * sheetAllowedDetents?: Array; 36 | * sheetInitialDetentIndex?: number; 37 | * 38 | * But here we specifically pass them for the web side via vaul: 39 | */ 40 | sheetAllowedDetents?: (number | string)[]; // e.g. [0.5, 1.0] or ['148px', '355px', 1] 41 | sheetInitialDetentIndex?: number; // which index in `sheetAllowedDetents` is the default 42 | sheetGrabberVisible?: boolean; 43 | }; 44 | 45 | type MyModalStackRouterOptions = DefaultRouterOptions & { 46 | // Extend if you need custom router logic 47 | }; 48 | 49 | type Props = { 50 | initialRouteName?: string; 51 | screenOptions?: MyModalStackNavigationOptions; 52 | children: React.ReactNode; 53 | }; 54 | 55 | function MyModalStackNavigator({ 56 | initialRouteName, 57 | children, 58 | screenOptions, 59 | }: Props) { 60 | const { state, navigation, descriptors, NavigationContent } = 61 | useNavigationBuilder< 62 | StackNavigationState, 63 | MyModalStackRouterOptions, 64 | MyModalStackNavigationOptions 65 | >(StackRouter, { 66 | children, 67 | screenOptions, 68 | initialRouteName, 69 | }); 70 | 71 | return ( 72 | 73 | 78 | 79 | ); 80 | } 81 | /** 82 | * Filters out "modal"/"formSheet" routes from the normal on web, 83 | * rendering them in a vaul with snap points. On native, we just let 84 | * React Navigation handle the sheet or modal transitions. 85 | */ 86 | function MyModalStackView({ 87 | state, 88 | navigation, 89 | descriptors, 90 | }: { 91 | state: StackNavigationState; 92 | navigation: any; 93 | descriptors: Record< 94 | string, 95 | { 96 | options: MyModalStackNavigationOptions; 97 | render: () => React.ReactNode; 98 | } 99 | >; 100 | }) { 101 | const isWeb = Platform.OS === "web"; 102 | 103 | // Filter out any route that wants to be shown as a modal on web 104 | const nonModalRoutes = state.routes.filter((route) => { 105 | const descriptor = descriptors[route.key]; 106 | const { presentation } = descriptor.options || {}; 107 | const isModalType = 108 | presentation === "modal" || 109 | presentation === "formSheet" || 110 | presentation === "fullScreenModal" || 111 | presentation === "containedModal"; 112 | return !(isWeb && isModalType); 113 | }); 114 | 115 | // Recalculate index so we don't point to a missing route on web 116 | let nonModalIndex = nonModalRoutes.findIndex( 117 | (r) => r.key === state.routes[state.index]?.key 118 | ); 119 | if (nonModalIndex < 0) { 120 | nonModalIndex = nonModalRoutes.length - 1; 121 | } 122 | 123 | const newStackState: StackNavigationState = { 124 | ...state, 125 | routes: nonModalRoutes, 126 | index: nonModalIndex, 127 | }; 128 | 129 | return ( 130 |
134 | {/* Normal stack rendering for native & non-modal routes on web */} 135 | 140 | 141 | {/* Render vaul Drawer for active "modal" route on web, with snap points */} 142 | {isWeb && 143 | state.routes.map((route, i) => { 144 | const descriptor = descriptors[route.key]; 145 | const { presentation, sheetAllowedDetents, sheetGrabberVisible } = 146 | descriptor.options || {}; 147 | 148 | const isModalType = 149 | presentation === "modal" || 150 | presentation === "formSheet" || 151 | presentation === "fullScreenModal" || 152 | presentation === "containedModal"; 153 | const isActive = i === state.index && isModalType; 154 | if (!isActive) return null; 155 | 156 | // Convert numeric detents (e.g. 0.5 => "50%") to a string 157 | // If user passes pixel or percentage strings, we'll keep them as is. 158 | const rawDetents = sheetAllowedDetents || [1]; 159 | 160 | return ( 161 | { 170 | if (!open) { 171 | navigation.goBack(); 172 | } 173 | }} 174 | > 175 | 176 | 183 | 187 |
188 | {/* Optional "grabber" */} 189 | {sheetGrabberVisible && ( 190 |
201 |
209 |
210 | )} 211 | 212 | {/* Render the actual screen */} 213 | {descriptor.render()} 214 |
215 | 216 | 217 | 218 | ); 219 | })} 220 |
221 | ); 222 | } 223 | 224 | const createMyModalStack = createNavigatorFactory(MyModalStackNavigator); 225 | 226 | /** 227 | * If you're using Expo Router, wrap with `withLayoutContext`. 228 | * Otherwise, just export the createMyModalStack().Navigator as usual. 229 | */ 230 | const RouterModal = withLayoutContext(createMyModalStack().Navigator); 231 | 232 | export default RouterModal; 233 | -------------------------------------------------------------------------------- /src/components/example/glurry-modal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | import * as Form from "@/components/ui/Form"; 4 | import { IconSymbol } from "@/components/ui/IconSymbol"; 5 | import * as AC from "@bacons/apple-colors"; 6 | import { Image } from "expo-image"; 7 | import { 8 | Modal, 9 | Pressable, 10 | StyleSheet, 11 | useColorScheme, 12 | View, 13 | } from "react-native"; 14 | import Animated, { 15 | Easing, 16 | FadeIn, 17 | FadeInDown, 18 | FadeOut, 19 | FadeOutDown, 20 | runOnJS, 21 | SlideInDown, 22 | SlideOutDown, 23 | useAnimatedProps, 24 | useSharedValue, 25 | withTiming, 26 | } from "react-native-reanimated"; 27 | 28 | import TouchableBounce from "@/components/ui/TouchableBounce"; 29 | import Masked from "@react-native-masked-view/masked-view"; 30 | import { BlurView } from "expo-blur"; 31 | import { impactAsync, ImpactFeedbackStyle } from "expo-haptics"; 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 33 | 34 | const ABlurView = Animated.createAnimatedComponent(BlurView); 35 | 36 | function AnimateInBlur({ 37 | intensity = 50, 38 | ...props 39 | }: React.ComponentProps) { 40 | "use no memo"; 41 | const sharedValue = useSharedValue(0); 42 | 43 | React.useEffect(() => { 44 | sharedValue.set( 45 | withTiming(intensity || 50, { 46 | duration: 500, 47 | easing: Easing.out(Easing.exp), 48 | }) 49 | ); 50 | }, [intensity]); 51 | 52 | React.useImperativeHandle(props.ref, () => ({ 53 | animateToZero: () => { 54 | return new Promise((resolve) => { 55 | sharedValue.value = withTiming( 56 | 0, 57 | { 58 | duration: 500, 59 | easing: Easing.out(Easing.exp), 60 | }, 61 | () => { 62 | runOnJS(resolve)(); 63 | } 64 | ); 65 | }); 66 | }, 67 | })); 68 | 69 | const animatedProps = useAnimatedProps(() => ({ 70 | intensity: sharedValue.value, 71 | })); 72 | return ; 73 | } 74 | 75 | const backgroundImage = 76 | process.env.EXPO_OS === "web" 77 | ? `backgroundImage` 78 | : `experimental_backgroundImage`; 79 | 80 | export function Glur({ direction }: { direction: "top" | "bottom" }) { 81 | return ( 82 | <> 83 | 84 | 85 | 86 | 87 | ); 88 | } 89 | 90 | function GlurLayer({ 91 | direction, 92 | falloff, 93 | intensity, 94 | }: { 95 | direction: "top" | "bottom"; 96 | falloff: number; 97 | intensity?: number; 98 | }) { 99 | return ( 100 | <> 101 | 114 | } 115 | style={{ 116 | position: "absolute", 117 | left: 0, 118 | right: 0, 119 | bottom: 0, 120 | height: 96, 121 | }} 122 | > 123 | 124 | 125 | 126 | ); 127 | } 128 | 129 | function GloryModal({ 130 | children, 131 | onClose, 132 | }: { 133 | children?: React.ReactNode; 134 | onClose: () => void; 135 | }) { 136 | const { bottom } = useSafeAreaInsets(); 137 | const ref = React.useRef<{ animateToZero: () => void }>(null); 138 | 139 | useEffect(() => { 140 | impactAsync(ImpactFeedbackStyle.Medium); 141 | }, []); 142 | 143 | const close = () => { 144 | ref.current?.animateToZero(); 145 | onClose(); 146 | }; 147 | 148 | const theme = useColorScheme(); 149 | 150 | return ( 151 | 158 | 159 | 171 | 172 | {children} 173 | 174 | {process.env.EXPO_OS !== "web" && ( 175 | 176 | 177 | 178 | )} 179 | 193 | 194 | 195 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | ); 213 | } 214 | 215 | export function GlurryList({ setShow }: { setShow: (show: boolean) => void }) { 216 | const providers = [ 217 | { 218 | title: "Expo", 219 | icon: "https://simpleicons.org/icons/expo.svg", 220 | color: "#000000", 221 | selected: true, 222 | }, 223 | { 224 | title: "Google", 225 | icon: "https://simpleicons.org/icons/google.svg", 226 | color: "#4285F4", 227 | }, 228 | { 229 | title: "Tesla", 230 | icon: "https://simpleicons.org/icons/tesla.svg", 231 | color: "#CC0000", 232 | }, 233 | { 234 | title: "Facebook", 235 | icon: "https://simpleicons.org/icons/facebook.svg", 236 | color: "#0866FF", 237 | }, 238 | { 239 | title: "GitHub", 240 | icon: "https://simpleicons.org/icons/github.svg", 241 | color: "#181717", 242 | }, 243 | ]; 244 | 245 | return ( 246 | setShow(false)}> 247 | 252 | setShow(false)}> 253 | 265 | 266 | {providers.map((provider) => ( 267 | { 271 | setShow(false); 272 | }} 273 | > 274 | 287 | 296 | 297 | 298 | {provider.title} 299 | 300 | 301 | 302 | {provider.selected && ( 303 | 309 | )} 310 | 311 | 312 | ))} 313 | 314 | 315 | 316 | 317 | 318 | ); 319 | } 320 | -------------------------------------------------------------------------------- /src/components/torus-dom.tsx: -------------------------------------------------------------------------------- 1 | "use dom"; 2 | import React, { useEffect, useRef } from "react"; 3 | import * as THREE from "three"; 4 | 5 | export default function ShaderScene({ 6 | style, 7 | speed, 8 | }: { 9 | style?: React.CSSProperties; 10 | dom?: import("expo/dom").DOMProps; 11 | speed?: number; 12 | }) { 13 | const mountRef = useRef(null); 14 | const uniformsRef = useRef({}); 15 | 16 | useEffect(() => { 17 | const uniforms = uniformsRef.current; 18 | if (uniforms?.speed) { 19 | uniforms.speed.value = speed; 20 | uniforms.direction.value = speed > 0 ? -1.0 : 1.0; 21 | } 22 | }, [speed]); 23 | 24 | useEffect(() => { 25 | const mount = mountRef.current; 26 | 27 | const scene = new THREE.Scene(); 28 | const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); 29 | const renderer = new THREE.WebGLRenderer(); 30 | renderer.setPixelRatio(window.devicePixelRatio); 31 | renderer.setSize(window.innerWidth, window.innerHeight); 32 | mount.appendChild(renderer.domElement); 33 | 34 | const geometry = new THREE.PlaneGeometry(2, 2); 35 | const uniforms = { 36 | iTime: { value: 0 }, 37 | iResolution: { 38 | value: new THREE.Vector2( 39 | window.innerWidth * window.devicePixelRatio, 40 | window.innerHeight * window.devicePixelRatio 41 | ), 42 | }, 43 | speed: { value: 0.5 }, 44 | direction: { value: -1.0 }, 45 | colorTint: { value: new THREE.Vector3(6, 6, 6) }, 46 | paletteShift: { value: 0.0 }, 47 | sphereSize: { value: 1.2 }, 48 | positionOffset: { value: new THREE.Vector3(-2.0, -2.0, 0) }, 49 | // positionOffset: { value: new THREE.Vector3(-1.8, 0, 0) }, 50 | }; 51 | uniformsRef.current = uniforms; 52 | 53 | const material = new THREE.ShaderMaterial({ 54 | uniforms, 55 | vertexShader: `varying vec2 vUv; 56 | void main() { 57 | vUv = uv; 58 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 59 | }`, 60 | // Based on https://www.shadertoy.com/view/WdB3Dw 61 | fragmentShader: ` 62 | uniform vec2 iResolution; 63 | uniform float iTime; 64 | uniform float speed; 65 | uniform float direction; 66 | uniform vec3 colorTint; 67 | uniform float paletteShift; 68 | uniform float sphereSize; 69 | uniform vec3 positionOffset; 70 | varying vec2 vUv; 71 | 72 | #define PI 3.14159265359 73 | 74 | void pR(inout vec2 p, float a) { 75 | p = cos(a)*p + sin(a)*vec2(p.y, -p.x); 76 | } 77 | 78 | float smax(float a, float b, float r) { 79 | vec2 u = max(vec2(r + a, r + b), vec2(0.0)); 80 | return min(-r, max(a, b)) + length(u); 81 | } 82 | 83 | vec3 pal(in float t, in vec3 a, in vec3 b, in vec3 c, in vec3 d) { 84 | return a + b*cos(6.28318*(c*t+d)); 85 | } 86 | 87 | vec3 spectrum(float n) { 88 | return pal(n + paletteShift, vec3(0.5,0.5,0.5), vec3(0.5,0.5,0.5), vec3(1.0,1.0,1.0), vec3(0.0,0.33,0.67)); 89 | } 90 | 91 | vec4 inverseStereographic(vec3 p, out float k) { 92 | k = 2.0/(1.0+dot(p,p)); 93 | return vec4(k*p, k-1.0); 94 | } 95 | 96 | float fTorus(vec4 p4) { 97 | float d1 = length(p4.xy) / length(p4.zw) - 1.0; 98 | float d2 = length(p4.zw) / length(p4.xy) - 1.0; 99 | float d = d1 < 0.0 ? -d1 : d2; 100 | d /= PI; 101 | return d; 102 | } 103 | 104 | float fixDistance(float d, float k) { 105 | float sn = sign(d); 106 | d = abs(d); 107 | d = d / k * 1.82; 108 | d += 1.0; 109 | d = pow(d, 0.5); 110 | d -= 1.0; 111 | d *= 5.0/3.0; 112 | d *= sn; 113 | return d; 114 | } 115 | 116 | float map(vec3 p, float time) { 117 | // Apply controls: reposition and scale the scene 118 | p = (p - positionOffset) / sphereSize; 119 | float k; 120 | vec4 p4 = inverseStereographic(p, k); 121 | pR(p4.zy, time * -PI / 2.0 * direction); 122 | pR(p4.xw, time * -PI / 2.0 * direction); 123 | float d = fTorus(p4); 124 | d = abs(d); 125 | d -= 0.2; 126 | d = fixDistance(d, k); 127 | d = smax(d, length(p) - 1.85, 0.2); 128 | return d; 129 | } 130 | 131 | mat3 calcLookAtMatrix(vec3 ro, vec3 ta, vec3 up) { 132 | vec3 ww = normalize(ta - ro); 133 | vec3 uu = normalize(cross(ww, up)); 134 | vec3 vv = normalize(cross(uu, ww)); 135 | return mat3(uu, vv, ww); 136 | } 137 | 138 | void main() { 139 | float time = mod(iTime * speed / 2.0, 1.0); 140 | vec3 camPos = vec3(1.8, 5.5, -5.5) * 1.75; 141 | vec3 camTar = vec3(0.0, 0.0, 0.0); 142 | vec3 camUp = vec3(-1.0, 0.0, -1.5); 143 | mat3 camMat = calcLookAtMatrix(camPos, camTar, camUp); 144 | // sphere size 145 | float focalLength = 2.0; 146 | vec2 p = (vUv * 2.0 - 1.0) * vec2(iResolution.x / iResolution.y, 1.0); 147 | 148 | vec3 rayDirection = normalize(camMat * vec3(p, focalLength)); 149 | vec3 rayPosition = camPos; 150 | float rayLength = 0.0; 151 | float distance = 0.0; 152 | vec3 color = vec3(0.0); 153 | vec3 c; 154 | 155 | const float ITER = 82.0; 156 | const float FUDGE_FACTORR = 0.8; 157 | const float INTERSECTION_PRECISION = 0.001; 158 | const float MAX_DIST = 20.0; 159 | 160 | for (float i = 0.0; i < ITER; i++) { 161 | rayLength += max(INTERSECTION_PRECISION, abs(distance) * FUDGE_FACTORR); 162 | rayPosition = camPos + rayDirection * rayLength; 163 | distance = map(rayPosition, time); 164 | c = vec3(max(0.0, 0.01 - abs(distance)) * 0.5); 165 | c *= colorTint; 166 | c += vec3(0.6, 0.25, 0.7) * FUDGE_FACTORR / 160.0; 167 | c *= smoothstep(20.0, 7.0, length(rayPosition)); 168 | float rl = smoothstep(MAX_DIST, 0.1, rayLength); 169 | c *= rl; 170 | c *= spectrum(rl * 6.0 - 0.6); 171 | color += c; 172 | if (rayLength > MAX_DIST) { 173 | break; 174 | } 175 | } 176 | 177 | color = pow(color, vec3(1.0 / 1.8)) * 2.0; 178 | color = pow(color, vec3(2.0)) * 3.0; 179 | color = pow(color, vec3(1.0 / 2.2)); 180 | 181 | float alpha = length(color); 182 | gl_FragColor = vec4(color, alpha); 183 | } 184 | `, 185 | }); 186 | 187 | const mesh = new THREE.Mesh(geometry, material); 188 | scene.add(mesh); 189 | 190 | const clock = new THREE.Clock(); 191 | const animate = () => { 192 | requestAnimationFrame(animate); 193 | uniforms.iTime.value = clock.getElapsedTime(); 194 | renderer.render(scene, camera); 195 | }; 196 | animate(); 197 | 198 | const onResize = () => { 199 | renderer.setSize(window.innerWidth, window.innerHeight); 200 | uniforms.iResolution.value.set( 201 | window.innerWidth * window.devicePixelRatio, 202 | window.innerHeight * window.devicePixelRatio 203 | ); 204 | }; 205 | window.addEventListener("resize", onResize); 206 | 207 | return () => { 208 | window.removeEventListener("resize", onResize); 209 | mount.removeChild(renderer.domElement); 210 | geometry.dispose(); 211 | material.dispose(); 212 | renderer.dispose(); 213 | }; 214 | }, []); 215 | 216 | return ( 217 |
218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /src/app/(index,info)/_debug.tsx: -------------------------------------------------------------------------------- 1 | import "@/components/runtime/local-storage"; 2 | 3 | import * as Form from "@/components/ui/Form"; 4 | import Constants, { ExecutionEnvironment } from "expo-constants"; 5 | 6 | import * as Clipboard from "expo-clipboard"; 7 | import { IconSymbol } from "@/components/ui/IconSymbol"; 8 | import * as AC from "@bacons/apple-colors"; 9 | import * as Updates from "expo-updates"; 10 | import { ActivityIndicator, Linking, View } from "react-native"; 11 | 12 | import * as Application from "expo-application"; 13 | import { router } from "expo-router"; 14 | import { useEffect, useState } from "react"; 15 | 16 | const ENV_SUPPORTS_OTA = 17 | process.env.EXPO_OS !== "web" && 18 | typeof window !== "undefined" && 19 | Constants.executionEnvironment !== ExecutionEnvironment.StoreClient; 20 | 21 | export default function DebugRoute() { 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | /_sitemap 29 | {process.env.EXPO_OS !== "web" && ( 30 | Linking.openSettings()} 32 | hint={} 33 | > 34 | Open System Settings 35 | 36 | )} 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | // Async function to get App Store link by bundle ID, with caching 46 | async function getAppStoreLink(bundleId: string) { 47 | // Check cache first 48 | const cachedLink = localStorage.getItem(`appStoreLink_${bundleId}`); 49 | if (cachedLink) { 50 | console.log(`Returning cached App Store link for ${bundleId}`); 51 | return cachedLink; 52 | } 53 | 54 | // Make API call to iTunes Search API 55 | const response = await fetch( 56 | `https://itunes.apple.com/lookup?bundleId=${encodeURIComponent(bundleId)}` 57 | ); 58 | 59 | // Check if response is OK 60 | if (!response.ok) { 61 | throw new Error( 62 | `Failed to query App Store URL. Status: ${response.status}` 63 | ); 64 | } 65 | 66 | const data = await response.json(); 67 | 68 | // Validate API response 69 | if (data.resultCount === 0 || !data.results[0]?.trackId) { 70 | throw new Error(`No app found for bundle ID on App Store: ${bundleId}`); 71 | } 72 | 73 | // Extract App ID and construct App Store link 74 | const appId = data.results[0].trackId; 75 | const appStoreLink = `https://apps.apple.com/app/id${appId}`; 76 | 77 | // Cache the successful result 78 | localStorage.setItem(`appStoreLink_${bundleId}`, appStoreLink); 79 | console.log(`Cached App Store link for ${bundleId}`); 80 | 81 | return appStoreLink; 82 | } 83 | 84 | async function getStoreUrlAsync() { 85 | if (process.env.EXPO_OS === "ios") { 86 | return await getAppStoreLinkAsync(); 87 | } else if (process.env.EXPO_OS === "android") { 88 | return `https://play.google.com/store/apps/details?id=${Application.applicationId}`; 89 | } 90 | return null; 91 | } 92 | 93 | async function getAppStoreLinkAsync() { 94 | if (process.env.EXPO_OS !== "ios") { 95 | return null; 96 | } 97 | try { 98 | const link = await getAppStoreLink(Application.applicationId!); 99 | return link; 100 | } catch (error: any) { 101 | console.error("Error fetching App Store link:", error); 102 | alert(error.message); 103 | return null; 104 | } 105 | } 106 | 107 | function AppStoreSection() { 108 | const [canOpenStore, setCanOpenStore] = useState(true); 109 | if (process.env.EXPO_OS === "web") { 110 | return null; 111 | } 112 | 113 | return ( 114 | 117 | { 120 | const appStoreLink = await getStoreUrlAsync(); 121 | setCanOpenStore(!!appStoreLink); 122 | console.log("App Store link:", appStoreLink); 123 | if (appStoreLink) { 124 | // @ts-ignore: external URL 125 | router.push(appStoreLink); 126 | } 127 | }} 128 | style={{ color: AC.systemBlue }} 129 | > 130 | {canOpenStore ? `Check for app updates` : "App not available"} 131 | 132 | 133 | {process.env.EXPO_OS === "ios" ? `Bundle ID` : "App ID"} 134 | 135 | 136 | ); 137 | } 138 | 139 | function ExpoSection() { 140 | const sdkVersion = (() => { 141 | const current = Constants.expoConfig?.sdkVersion; 142 | if (current && current.includes(".")) { 143 | return current.split(".").shift(); 144 | } 145 | return current ?? "unknown"; 146 | })(); 147 | 148 | const [envName, setEnvName] = useState(null); 149 | useEffect(() => { 150 | getReleaseTypeAsync().then((name) => { 151 | setEnvName(name); 152 | }); 153 | }, []); 154 | 155 | const hermes = getHermesVersion(); 156 | 157 | return ( 158 | <> 159 | 160 | Environment 161 | {hermes && Hermes} 162 | 163 | Mode 164 | 165 | 166 | 167 | { 172 | Clipboard.setStringAsync(getDeploymentUrl()); 173 | alert("Copied to clipboard"); 174 | }} 175 | href={getDeploymentUrl()} 176 | > 177 | Expo Dashboard 178 | 179 | 180 | 181 | 182 | Host 183 | 184 | 185 | ); 186 | } 187 | 188 | function OTADynamicSection() { 189 | if (process.env.EXPO_OS === "web") { 190 | return null; 191 | } 192 | const updates = Updates.useUpdates(); 193 | 194 | const fetchingTitle = updates.isDownloading 195 | ? `Downloading...` 196 | : updates.isChecking 197 | ? `Checking for updates...` 198 | : updates.isUpdateAvailable 199 | ? "Reload app" 200 | : "Check again"; 201 | 202 | const checkError = updates.checkError; 203 | // const checkError = new Error( 204 | // "really long error name that hs sefsef sef sef sefsef sef eorhsoeuhfsef fselfkjhslehfse f" 205 | // ); // updates.checkError; 206 | 207 | const lastCheckTime = ( 208 | updates.lastCheckForUpdateTimeSinceRestart 209 | ? new Date(updates.lastCheckForUpdateTimeSinceRestart) 210 | : new Date() 211 | ).toLocaleString("en-US", { 212 | timeZoneName: "short", 213 | dateStyle: "short", 214 | timeStyle: "short", 215 | }); 216 | 217 | const isLoading = updates.isChecking || updates.isDownloading; 218 | return ( 219 | <> 220 | : lastCheckTime} 225 | > 226 | { 232 | if (__DEV__ && !ENV_SUPPORTS_OTA) { 233 | alert("OTA updates are not available in the Expo Go app."); 234 | return; 235 | } 236 | if (updates.availableUpdate) { 237 | Updates.reloadAsync(); 238 | } else { 239 | Updates.checkForUpdateAsync(); 240 | } 241 | }} 242 | hint={ 243 | isLoading ? ( 244 | 245 | ) : ( 246 | 247 | ) 248 | } 249 | > 250 | {fetchingTitle} 251 | 252 | {checkError && ( 253 | 254 | 255 | Error checking status 256 | 257 | {/* Spacer */} 258 | 259 | {/* Right */} 260 | 261 | {checkError.message} 262 | 263 | 264 | )} 265 | 266 | 267 | ); 268 | } 269 | 270 | function OTASection() { 271 | return ( 272 | <> 273 | 274 | Runtime version 275 | Channel 276 | 281 | Created 282 | 283 | Embedded 284 | 285 | Emergency Launch 286 | 287 | 288 | Launch Duration 289 | 290 | ID 291 | 292 | 293 | ); 294 | } 295 | 296 | function getHermesVersion() { 297 | // @ts-expect-error 298 | const HERMES_RUNTIME = global.HermesInternal?.getRuntimeProperties?.() ?? {}; 299 | const HERMES_VERSION = HERMES_RUNTIME["OSS Release Version"]; 300 | const isStaticHermes = HERMES_RUNTIME["Static Hermes"]; 301 | 302 | if (!HERMES_RUNTIME) { 303 | return null; 304 | } 305 | 306 | if (isStaticHermes) { 307 | return `${HERMES_VERSION} (shermes)`; 308 | } 309 | return HERMES_VERSION; 310 | } 311 | 312 | async function getReleaseTypeAsync() { 313 | if (process.env.EXPO_OS === "ios") { 314 | const releaseType = await Application.getIosApplicationReleaseTypeAsync(); 315 | 316 | const suffix = (() => { 317 | switch (releaseType) { 318 | case Application.ApplicationReleaseType.AD_HOC: 319 | return "Ad Hoc"; 320 | case Application.ApplicationReleaseType.ENTERPRISE: 321 | return "Enterprise"; 322 | case Application.ApplicationReleaseType.DEVELOPMENT: 323 | return "Development"; 324 | case Application.ApplicationReleaseType.APP_STORE: 325 | return "App Store"; 326 | case Application.ApplicationReleaseType.SIMULATOR: 327 | return "Simulator"; 328 | case Application.ApplicationReleaseType.UNKNOWN: 329 | default: 330 | return "unknown"; 331 | } 332 | })(); 333 | return `${Application.applicationName} (${suffix})`; 334 | } else if (process.env.EXPO_OS === "android") { 335 | return `${Application.applicationName}`; 336 | } 337 | 338 | return null; 339 | } 340 | 341 | // Get the linked server deployment URL for the current app. This makes it easy to open 342 | // the Expo dashboard and check errors/analytics for the current version of the app you're using. 343 | function getDeploymentUrl(): any { 344 | const deploymentId = (() => { 345 | // https://expo.dev/accounts/bacon/projects/expo-ai/hosting/deployments/o70t5q6t0r/requests 346 | const origin = Constants.expoConfig?.extra?.router?.origin; 347 | if (!origin) { 348 | return null; 349 | } 350 | try { 351 | const url = new URL(origin); 352 | // Should be like: https://exai--xxxxxx.expo.app 353 | // We need to extract the `xxxxxx` part if the URL matches `[\w\d]--([])`. 354 | return url.hostname.match(/(?:[^-]+)--([^.]+)\.expo\.app/)?.[1] ?? null; 355 | } catch { 356 | return null; 357 | } 358 | })(); 359 | 360 | const dashboardUrl = (() => { 361 | // TODO: There might be a better way to do this, using the project ID. 362 | const projectId = Constants.expoConfig?.extra?.eas?.projectId; 363 | if (projectId) { 364 | // https://expo.dev/projects/[uuid] 365 | return `https://expo.dev/projects/${projectId}`; 366 | } 367 | const owner = Constants.expoConfig?.owner ?? "[account]"; 368 | const slug = Constants.expoConfig?.slug ?? "[project]"; 369 | 370 | return `https://expo.dev/accounts/${owner}/projects/${slug}`; 371 | })(); 372 | 373 | let deploymentUrl = `${dashboardUrl}/hosting/deployments`; 374 | if (deploymentId) { 375 | deploymentUrl += `/${deploymentId}/requests`; 376 | } 377 | return deploymentUrl; 378 | } 379 | -------------------------------------------------------------------------------- /src/app/(index,info)/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import * as Form from "@/components/ui/Form"; 4 | import { IconSymbol } from "@/components/ui/IconSymbol"; 5 | import { 6 | Segments, 7 | SegmentsContent, 8 | SegmentsList, 9 | SegmentsTrigger, 10 | } from "@/components/ui/Segments"; 11 | import Stack from "@/components/ui/Stack"; 12 | import * as AC from "@bacons/apple-colors"; 13 | import { Image } from "expo-image"; 14 | import * as SplashScreen from "expo-splash-screen"; 15 | import { ComponentProps } from "react"; 16 | import { 17 | OpaqueColorValue, 18 | StyleSheet, 19 | Text, 20 | TextProps, 21 | View, 22 | } from "react-native"; 23 | import Animated, { 24 | interpolate, 25 | useAnimatedRef, 26 | useAnimatedStyle, 27 | useScrollViewOffset, 28 | } from "react-native-reanimated"; 29 | 30 | import { Glur, GlurryList } from "@/components/example/glurry-modal"; 31 | import ShaderScene from "@/components/torus-dom"; 32 | import TouchableBounce from "@/components/ui/TouchableBounce"; 33 | import ExpoSvg from "@/svg/expo.svg"; 34 | import { BlurView } from "expo-blur"; 35 | import { Link } from "expo-router"; 36 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 37 | 38 | function ProminentHeaderButton({ 39 | children, 40 | ...props 41 | }: { 42 | children?: React.ReactNode; 43 | }) { 44 | return ( 45 | {}} {...props}> 46 | 60 | {children} 61 | 62 | 63 | ); 64 | } 65 | 66 | export default function Page() { 67 | const ref = useAnimatedRef(); 68 | const scroll = useScrollViewOffset(ref); 69 | const style = useAnimatedStyle(() => { 70 | return { 71 | opacity: interpolate(scroll.value, [0, 30], [0, 1], "clamp"), 72 | transform: [ 73 | { translateY: interpolate(scroll.value, [0, 30], [5, 0], "clamp") }, 74 | ], 75 | }; 76 | }); 77 | const blurStyle = useAnimatedStyle(() => { 78 | return { 79 | opacity: interpolate(scroll.value, [0, 200], [0, 1], "clamp"), 80 | }; 81 | }); 82 | const shaderStyle = useAnimatedStyle(() => { 83 | return { 84 | transform: [ 85 | { 86 | scale: interpolate( 87 | scroll.value, 88 | [-200, 0, 800], 89 | [1, 1.2, 1.7], 90 | "clamp" 91 | ), 92 | }, 93 | ], 94 | }; 95 | }); 96 | const headerStyle = useAnimatedStyle(() => { 97 | return { 98 | opacity: interpolate(scroll.value, [100, 200], [0, 1], "clamp"), 99 | }; 100 | }); 101 | 102 | const [show, setShow] = React.useState(false); 103 | const { top } = useSafeAreaInsets(); 104 | return ( 105 | 106 | 119 | 120 | 121 | 122 | 140 | { 142 | window.location.reload(); 143 | }} 144 | > 145 | 146 | 147 | 148 | 149 | 154 | 155 | 156 | 157 | 158 | 161 | { 166 | SplashScreen.hideAsync(); 167 | }, 1); 168 | }, 169 | style: { 170 | flex: 1, 171 | }, 172 | contentInsetAdjustmentBehavior: "never", 173 | automaticallyAdjustContentInsets: false, 174 | 175 | containerStyle: { 176 | position: "absolute", 177 | top: 0, 178 | left: 0, 179 | right: 0, 180 | bottom: 0, 181 | zIndex: -1, 182 | }, 183 | }} 184 | speed={0.2} 185 | style={{ 186 | position: "absolute", 187 | top: 0, 188 | left: 0, 189 | right: 0, 190 | bottom: 0, 191 | }} 192 | /> 193 | 194 | 197 | {/* */} 206 | 211 | 212 | 213 | {show && } 214 | 227 | 239 | 246 | Bacon Components 247 | 248 | 249 | ); 250 | } 251 | return ( 252 | 265 | ); 266 | }, 267 | }} 268 | /> 269 | 277 | 288 | 289 | 298 | {`Enter\nCyberspace`} 299 | 300 | 301 | 302 | 303 | Future Software 304 | {/* Spacer */} 305 | 306 | {/* Right */} 307 | 308 | Apps from future generations, plucked out of cyber space. 309 | 310 | 311 | 312 | 313 | 314 | { 316 | setShow(true); 317 | }} 318 | > 319 | Choose Model 320 | 321 | Change App Icon 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 339 | } 340 | style={{ color: AC.label, fontWeight: "600" }} 341 | > 342 | Deploy on Expo 343 | 344 | 345 | 346 | 352 | {`~/ `} 353 | npx testflight 354 | 355 | 356 | 362 | {`~/ `} 363 | eas deploy 364 | 365 | 366 | 367 | 368 | 369 | 377 | 386 | 387 | 388 | Evan Bacon 389 | 390 | Artist and Developer 391 | 392 | 393 | 394 | 395 | 396 | 407 | 408 | 409 | 410 | 411 | Release Date 412 | Version 413 | 414 | 419 | Compatibility 420 | 421 | 422 | 423 | 424 | ); 425 | } 426 | 427 | // Text animation that animates in the text character by character but first showing a random hash character while typing out. 428 | function GlitchTextAnimation({ 429 | children, 430 | ...props 431 | }: { children: string } & TextProps) { 432 | // Return a set of entropy characters that are similar to the original character 433 | const getEntropyChars = (char: string) => { 434 | const englishToLatin: Record = { 435 | a: "α", 436 | b: "β", 437 | c: "γ", 438 | d: "δ", 439 | e: "ε", 440 | f: "φ", 441 | g: "γ", 442 | h: "η", 443 | i: "ι", 444 | j: "ϳ", 445 | k: "κ", 446 | l: "λ", 447 | m: "μ", 448 | n: "ν", 449 | o: "ο", 450 | p: "π", 451 | q: "θ", 452 | r: "ρ", 453 | s: "σ", 454 | t: "τ", 455 | u: "υ", 456 | v: "ϐ", 457 | w: "ω", 458 | x: "χ", 459 | C: "Γ", 460 | D: "Δ", 461 | F: "Φ", 462 | G: "Γ", 463 | J: "Ϗ", 464 | L: "Λ", 465 | P: "Π", 466 | Q: "Θ", 467 | R: "Ρ", 468 | S: "Σ", 469 | V: "ϐ", 470 | W: "Ω", 471 | "0": "𝟘", 472 | "1": "𝟙", 473 | "2": "𝟚", 474 | "3": "𝟛", 475 | }; 476 | for (const charset of [ 477 | "ijl|!¡ƒ†", 478 | "ceoauøπ∂", 479 | "CEOAUV", 480 | 481 | // letters of same width 482 | "abcdefghijklmnopqrstuvwxyzπ∂ƒ†", 483 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ∆#𝝠𝝠𝝠", 484 | // numbers 485 | "0123456789", 486 | ]) { 487 | if (charset.includes(char)) { 488 | const chars = charset.split("").filter((c) => c !== char); 489 | 490 | if (englishToLatin[char]) { 491 | return [englishToLatin[char], ...chars]; 492 | } 493 | return chars; 494 | } 495 | } 496 | 497 | return ["#", "%", "&", "*"]; 498 | }; 499 | 500 | const randomEntropyChar = (char: string) => { 501 | const chars = getEntropyChars(char); 502 | return chars[Math.floor(Math.random() * chars.length)]; 503 | }; 504 | 505 | // Initialize displayText while preserving whitespace. 506 | const initialText = Array.from(children, (char) => 507 | /\s/.test(char) ? char : randomEntropyChar(char) 508 | ); 509 | 510 | const [displayText, setDisplayText] = React.useState(initialText); 511 | 512 | React.useEffect(() => { 513 | const intervals: NodeJS.Timeout[] = []; 514 | const timeouts: NodeJS.Timeout[] = []; 515 | 516 | for (let i = 0; i < children.length; i++) { 517 | // Preserve whitespace characters. 518 | if (/\s/.test(children[i])) { 519 | continue; 520 | } 521 | 522 | // Update the character by cycling through similar entropy characters. 523 | const interval = setInterval(() => { 524 | setDisplayText((prev) => { 525 | if (prev[i] !== children[i]) { 526 | const newText = [...prev]; 527 | newText[i] = randomEntropyChar(children[i]); 528 | return newText; 529 | } 530 | return prev; 531 | }); 532 | }, 50); 533 | intervals.push(interval); 534 | 535 | // Reveal the actual character after a delay based on its position. 536 | const timeout = setTimeout(() => { 537 | clearInterval(interval); 538 | setDisplayText((prev) => { 539 | const newText = [...prev]; 540 | newText[i] = children[i]; 541 | return newText; 542 | }); 543 | }, i * 100); 544 | timeouts.push(timeout); 545 | } 546 | 547 | return () => { 548 | intervals.forEach(clearInterval); 549 | timeouts.forEach(clearTimeout); 550 | }; 551 | }, [children]); 552 | 553 | return {displayText.join("")}; 554 | } 555 | 556 | function FormExpandable({ 557 | children, 558 | hint, 559 | preview, 560 | }: { 561 | custom: true; 562 | children?: React.ReactNode; 563 | hint?: string; 564 | preview?: string; 565 | }) { 566 | const [open, setOpen] = React.useState(false); 567 | 568 | // TODO: If the entire preview can fit, then just skip the hint. 569 | 570 | return ( 571 | setOpen(!open)}> 572 | 573 | {children} 574 | {/* Spacer */} 575 | 576 | {open && ( 577 | 582 | )} 583 | {/* Right */} 584 | 585 | {open ? hint : preview} 586 | 587 | {!open && ( 588 | 593 | )} 594 | 595 | 596 | ); 597 | } 598 | 599 | function FormLabel({ 600 | children, 601 | systemImage, 602 | color, 603 | }: { 604 | /** Only used when `` is a direct child of `
`. */ 605 | onPress?: () => void; 606 | children: React.ReactNode; 607 | systemImage: ComponentProps["name"]; 608 | color?: OpaqueColorValue; 609 | }) { 610 | return ( 611 | 612 | 613 | {children} 614 | 615 | ); 616 | } 617 | 618 | function SegmentsTest() { 619 | return ( 620 | 621 | 622 | 623 | Account 624 | Password 625 | 626 | 627 | 628 | Account Section 629 | 630 | 631 | 632 | Password Section 633 | 634 | 635 | 636 | 637 | ); 638 | } 639 | 640 | function TripleItemTest() { 641 | return ( 642 | <> 643 | 644 | 645 | 654 | 655 | 670 | } 671 | subtitle="Evan Bacon" 672 | /> 673 | 674 | 683 | 684 | 685 | 686 | ); 687 | } 688 | 689 | function HorizontalItem({ 690 | title, 691 | badge, 692 | subtitle, 693 | }: { 694 | title: string; 695 | badge: React.ReactNode; 696 | subtitle: string; 697 | }) { 698 | return ( 699 | 700 | 708 | {title} 709 | 710 | {typeof badge === "string" ? ( 711 | 718 | {badge} 719 | 720 | ) : ( 721 | badge 722 | )} 723 | 724 | 730 | {subtitle} 731 | 732 | 733 | ); 734 | } 735 | -------------------------------------------------------------------------------- /src/components/ui/Form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { IconSymbol, IconSymbolName } from "@/components/ui/IconSymbol"; 4 | import * as AppleColors from "@bacons/apple-colors"; 5 | import { BlurView } from "expo-blur"; 6 | import { Href, LinkProps, Link as RouterLink, Stack } from "expo-router"; 7 | import { SymbolWeight } from "expo-symbols"; 8 | import * as WebBrowser from "expo-web-browser"; 9 | import React from "react"; 10 | import { 11 | Button, 12 | GestureResponderEvent, 13 | OpaqueColorValue, 14 | RefreshControl, 15 | Text as RNText, 16 | ScrollViewProps, 17 | Share, 18 | StyleProp, 19 | StyleSheet, 20 | TextProps, 21 | TextStyle, 22 | TouchableHighlight, 23 | View, 24 | ViewProps, 25 | ViewStyle, 26 | } from "react-native"; 27 | import Animated from "react-native-reanimated"; 28 | import { BodyScrollView } from "./BodyScrollView"; 29 | import { HeaderButton } from "./Header"; 30 | 31 | type ListStyle = "grouped" | "auto"; 32 | 33 | const ListStyleContext = React.createContext("auto"); 34 | 35 | type RefreshCallback = () => Promise; 36 | 37 | const RefreshContext = React.createContext<{ 38 | subscribe: (cb: RefreshCallback) => () => void; 39 | hasSubscribers: boolean; 40 | refresh: () => Promise; 41 | refreshing: boolean; 42 | }>({ 43 | subscribe: () => () => {}, 44 | hasSubscribers: false, 45 | refresh: async () => {}, 46 | refreshing: false, 47 | }); 48 | 49 | const RefreshContextProvider: React.FC<{ 50 | children: React.ReactNode; 51 | }> = ({ children }) => { 52 | const subscribersRef = React.useRef>(new Set()); 53 | const [subscriberCount, setSubscriberCount] = React.useState(0); 54 | const [refreshing, setRefreshing] = React.useState(false); 55 | 56 | const subscribe = (cb: RefreshCallback) => { 57 | subscribersRef.current.add(cb); 58 | setSubscriberCount((count) => count + 1); 59 | 60 | return () => { 61 | subscribersRef.current.delete(cb); 62 | setSubscriberCount((count) => count - 1); 63 | }; 64 | }; 65 | 66 | const refresh = async () => { 67 | const subscribers = Array.from(subscribersRef.current); 68 | if (subscribers.length === 0) return; 69 | 70 | setRefreshing(true); 71 | try { 72 | await Promise.all(subscribers.map((cb) => cb())); 73 | } finally { 74 | setRefreshing(false); 75 | } 76 | }; 77 | 78 | return ( 79 | 0, 85 | }} 86 | > 87 | {children} 88 | 89 | ); 90 | }; 91 | 92 | /** 93 | * Register a callback to be called when the user pulls down to refresh in the nearest list. 94 | * 95 | * @param callback Register a function to be called when the user pulls down to refresh. 96 | * The function should return a promise that resolves when the refresh is complete. 97 | * @returns A function that can be called to trigger a list-wide refresh. 98 | */ 99 | export function useListRefresh(callback?: () => Promise) { 100 | const { subscribe, refresh } = React.use(RefreshContext); 101 | 102 | React.useEffect(() => { 103 | if (callback) { 104 | const unsubscribe = subscribe(callback); 105 | return unsubscribe; 106 | } 107 | }, [callback, subscribe]); 108 | 109 | return refresh; 110 | } 111 | 112 | type ListProps = ScrollViewProps & { 113 | /** Set the Expo Router `` title when mounted. */ 114 | navigationTitle?: string; 115 | listStyle?: ListStyle; 116 | }; 117 | export function List(props: ListProps) { 118 | return ( 119 | 120 | 121 | 122 | ); 123 | } 124 | if (__DEV__) List.displayName = "FormList"; 125 | 126 | function InnerList({ contentContainerStyle, ...props }: ListProps) { 127 | const { hasSubscribers, refreshing, refresh } = React.use(RefreshContext); 128 | 129 | return ( 130 | <> 131 | {props.navigationTitle && ( 132 | 133 | )} 134 | 135 | 152 | ) : undefined 153 | } 154 | {...props} 155 | /> 156 | 157 | 158 | ); 159 | } 160 | 161 | export function HStack(props: ViewProps) { 162 | return ( 163 | 177 | ); 178 | } 179 | 180 | const minItemHeight = 20; 181 | 182 | const styles = StyleSheet.create({ 183 | itemPadding: { 184 | paddingVertical: 11, 185 | paddingHorizontal: 20, 186 | }, 187 | }); 188 | 189 | export function FormItem({ 190 | children, 191 | href, 192 | onPress, 193 | onLongPress, 194 | style, 195 | ref, 196 | }: Pick & { 197 | href?: Href; 198 | onPress?: (event: any) => void; 199 | onLongPress?: (event: GestureResponderEvent) => void; 200 | style?: ViewStyle; 201 | ref?: React.Ref; 202 | }) { 203 | if (href == null) { 204 | if (onPress == null && onLongPress == null) { 205 | return ( 206 | 207 | {children} 208 | 209 | ); 210 | } 211 | return ( 212 | 218 | 219 | {children} 220 | 221 | 222 | ); 223 | } 224 | 225 | return ( 226 | 227 | 228 | 229 | {children} 230 | 231 | 232 | 233 | ); 234 | } 235 | 236 | const Colors = { 237 | systemGray4: AppleColors.systemGray4, // "rgba(209, 209, 214, 1)", 238 | secondarySystemGroupedBackground: 239 | AppleColors.secondarySystemGroupedBackground, // "rgba(255, 255, 255, 1)", 240 | separator: AppleColors.separator, // "rgba(61.2, 61.2, 66, 0.29)", 241 | }; 242 | 243 | type SystemImageCustomProps = { 244 | name: IconSymbolName; 245 | color?: OpaqueColorValue; 246 | size?: number; 247 | weight?: SymbolWeight; 248 | style?: StyleProp; 249 | }; 250 | 251 | type SystemImageProps = IconSymbolName | SystemImageCustomProps; 252 | 253 | /** Text but with iOS default color and sizes. */ 254 | export function Text({ 255 | bold, 256 | ...props 257 | }: TextProps & { 258 | /** Value displayed on the right side of the form item. */ 259 | hint?: React.ReactNode; 260 | /** A true/false value for the hint. */ 261 | hintBoolean?: React.ReactNode; 262 | /** Adds a prefix SF Symbol image to the left of the text */ 263 | systemImage?: SystemImageProps; 264 | 265 | bold?: boolean; 266 | }) { 267 | const font: TextStyle = { 268 | ...FormFont.default, 269 | flexShrink: 0, 270 | fontWeight: bold ? "600" : "normal", 271 | }; 272 | 273 | return ( 274 | 279 | ); 280 | } 281 | 282 | if (__DEV__) Text.displayName = "FormText"; 283 | 284 | export function Link({ 285 | bold, 286 | children, 287 | headerRight, 288 | hintImage, 289 | ...props 290 | }: LinkProps & { 291 | /** Value displayed on the right side of the form item. */ 292 | hint?: React.ReactNode; 293 | /** Adds a prefix SF Symbol image to the left of the text. */ 294 | systemImage?: SystemImageProps | React.ReactNode; 295 | 296 | /** Changes the right icon. */ 297 | hintImage?: SystemImageProps | React.ReactNode; 298 | 299 | // TODO: Automatically detect this somehow. 300 | /** Is the link inside a header. */ 301 | headerRight?: boolean; 302 | 303 | bold?: boolean; 304 | }) { 305 | const font: TextStyle = { 306 | ...FormFont.default, 307 | fontWeight: bold ? "600" : "normal", 308 | }; 309 | 310 | const resolvedChildren = (() => { 311 | if (headerRight) { 312 | if (process.env.EXPO_OS === "web") { 313 | return
{children}
; 314 | } 315 | const wrappedTextChildren = React.Children.map(children, (child) => { 316 | // Filter out empty children 317 | if (!child) { 318 | return null; 319 | } 320 | if (typeof child === "string") { 321 | return ( 322 | ( 324 | { ...font, color: AppleColors.link }, 325 | props.style 326 | )} 327 | > 328 | {child} 329 | 330 | ); 331 | } 332 | return child; 333 | }); 334 | 335 | return ( 336 | 344 | {wrappedTextChildren} 345 | 346 | ); 347 | } 348 | return children; 349 | })(); 350 | 351 | return ( 352 | (font, props.style)} 359 | onPress={ 360 | process.env.EXPO_OS === "web" 361 | ? props.onPress 362 | : (e) => { 363 | if ( 364 | props.target === "_blank" && 365 | // Ensure the resolved href is an external URL. 366 | /^([\w\d_+.-]+:)?\/\//.test(RouterLink.resolveHref(props.href)) 367 | ) { 368 | // Prevent the default behavior of linking to the default browser on native. 369 | e.preventDefault(); 370 | // Open the link in an in-app browser. 371 | WebBrowser.openBrowserAsync(props.href as string, { 372 | presentationStyle: 373 | WebBrowser.WebBrowserPresentationStyle.AUTOMATIC, 374 | }); 375 | } else if ( 376 | props.target === "share" && 377 | // Ensure the resolved href is an external URL. 378 | /^([\w\d_+.-]+:)?\/\//.test(RouterLink.resolveHref(props.href)) 379 | ) { 380 | // Prevent the default behavior of linking to the default browser on native. 381 | e.preventDefault(); 382 | // Open the link in an in-app browser. 383 | Share.share({ 384 | url: props.href as string, 385 | }); 386 | } else { 387 | props.onPress?.(e); 388 | } 389 | } 390 | } 391 | children={resolvedChildren} 392 | /> 393 | ); 394 | } 395 | 396 | if (__DEV__) Link.displayName = "FormLink"; 397 | 398 | export const FormFont = { 399 | // From inspecting SwiftUI `List { Text("Foo") }` in Xcode. 400 | default: { 401 | color: AppleColors.label, 402 | // 17.00pt is the default font size for a Text in a List. 403 | fontSize: 17, 404 | // UICTFontTextStyleBody is the default fontFamily. 405 | }, 406 | secondary: { 407 | color: AppleColors.secondaryLabel, 408 | fontSize: 17, 409 | }, 410 | caption: { 411 | color: AppleColors.secondaryLabel, 412 | fontSize: 12, 413 | }, 414 | title: { 415 | color: AppleColors.label, 416 | fontSize: 17, 417 | fontWeight: "600", 418 | }, 419 | }; 420 | 421 | export function Section({ 422 | children, 423 | title, 424 | titleHint, 425 | footer, 426 | ...props 427 | }: ViewProps & { 428 | title?: string | React.ReactNode; 429 | titleHint?: string | React.ReactNode; 430 | footer?: string | React.ReactNode; 431 | }) { 432 | const listStyle = React.use(ListStyleContext) ?? "auto"; 433 | 434 | const allChildren: React.ReactNode[] = []; 435 | 436 | React.Children.map(children, (child, index) => { 437 | if (!React.isValidElement(child)) { 438 | return child; 439 | } 440 | 441 | // If the child is a fragment, unwrap it and add the children to the list 442 | if (child.type === React.Fragment && child.props?.key == null) { 443 | React.Children.forEach(child.props?.children, (child) => { 444 | if (!React.isValidElement(child)) { 445 | return child; 446 | } 447 | allChildren.push(child); 448 | }); 449 | return; 450 | } 451 | 452 | allChildren.push(child); 453 | }); 454 | 455 | const childrenWithSeparator = allChildren.map((child, index) => { 456 | if (!React.isValidElement(child)) { 457 | return child; 458 | } 459 | const isLastChild = index === allChildren.length - 1; 460 | 461 | const resolvedProps = { 462 | ...child.props, 463 | }; 464 | 465 | // Set the hint for the hintBoolean prop. 466 | if (resolvedProps.hintBoolean != null) { 467 | resolvedProps.hint ??= resolvedProps.hintBoolean ? ( 468 | 472 | ) : ( 473 | 474 | ); 475 | } 476 | 477 | // Extract onPress from child 478 | const originalOnPress = resolvedProps.onPress; 479 | const originalOnLongPress = resolvedProps.onLongPress; 480 | let wrapsFormItem = false; 481 | if (child.type === Button) { 482 | const { title, color } = resolvedProps; 483 | 484 | delete resolvedProps.title; 485 | resolvedProps.style = mergedStyleProp( 486 | { color: color ?? AppleColors.link }, 487 | resolvedProps.style 488 | ); 489 | child = {title}; 490 | } 491 | 492 | if ( 493 | // If child is type of Text, add default props 494 | child.type === RNText || 495 | child.type === Text 496 | ) { 497 | child = React.cloneElement(child, { 498 | dynamicTypeRamp: "body", 499 | numberOfLines: 1, 500 | adjustsFontSizeToFit: true, 501 | ...resolvedProps, 502 | onPress: undefined, 503 | onLongPress: undefined, 504 | style: mergedStyleProp(FormFont.default, resolvedProps.style), 505 | }); 506 | 507 | const hintView = (() => { 508 | if (!resolvedProps.hint) { 509 | return null; 510 | } 511 | 512 | return React.Children.map(resolvedProps.hint, (child) => { 513 | // Filter out empty children 514 | if (!child) { 515 | return null; 516 | } 517 | if (typeof child === "string") { 518 | return ( 519 | 528 | {child} 529 | 530 | ); 531 | } 532 | return child; 533 | }); 534 | })(); 535 | 536 | if (hintView || resolvedProps.systemImage) { 537 | child = ( 538 | 539 | 543 | {child} 544 | {hintView && } 545 | {hintView} 546 | 547 | ); 548 | } 549 | } else if (child.type === RouterLink || child.type === Link) { 550 | wrapsFormItem = true; 551 | 552 | const wrappedTextChildren = React.Children.map( 553 | resolvedProps.children, 554 | (linkChild) => { 555 | // Filter out empty children 556 | if (!linkChild) { 557 | return null; 558 | } 559 | if (typeof linkChild === "string") { 560 | return ( 561 | 565 | {linkChild} 566 | 567 | ); 568 | } 569 | return linkChild; 570 | } 571 | ); 572 | 573 | const hintView = (() => { 574 | if (!resolvedProps.hint) { 575 | return null; 576 | } 577 | 578 | return React.Children.map(resolvedProps.hint, (child) => { 579 | // Filter out empty children 580 | if (!child) { 581 | return null; 582 | } 583 | if (typeof child === "string") { 584 | return ( 585 | 586 | {child} 587 | 588 | ); 589 | } 590 | return child; 591 | }); 592 | })(); 593 | 594 | child = React.cloneElement(child, { 595 | style: [ 596 | FormFont.default, 597 | process.env.EXPO_OS === "web" && { 598 | alignItems: "stretch", 599 | flexDirection: "column", 600 | display: "flex", 601 | }, 602 | resolvedProps.style, 603 | ], 604 | dynamicTypeRamp: "body", 605 | numberOfLines: 1, 606 | adjustsFontSizeToFit: true, 607 | // TODO: This causes issues with ref in React 19. 608 | asChild: process.env.EXPO_OS !== "web", 609 | children: ( 610 | 611 | 612 | 616 | {wrappedTextChildren} 617 | 618 | {hintView} 619 | 620 | 624 | 625 | 626 | 627 | ), 628 | }); 629 | } 630 | 631 | // Ensure child is a FormItem otherwise wrap it in a FormItem 632 | if (!wrapsFormItem && !child.props.custom && child.type !== FormItem) { 633 | child = ( 634 | 635 | {child} 636 | 637 | ); 638 | } 639 | 640 | return ( 641 | <> 642 | {child} 643 | {!isLastChild && } 644 | 645 | ); 646 | }); 647 | 648 | const contents = ( 649 | 668 | 672 | {childrenWithSeparator} 673 | 674 | ); 675 | 676 | const padding = listStyle === "grouped" ? 0 : 16; 677 | 678 | if (!title && !footer) { 679 | return ( 680 | 685 | {contents} 686 | 687 | ); 688 | } 689 | 690 | const titleHintJsx = (() => { 691 | if (!titleHint) { 692 | return null; 693 | } 694 | 695 | if (isStringishNode(titleHint)) { 696 | return ( 697 | 705 | {titleHint} 706 | 707 | ); 708 | } 709 | 710 | return titleHint; 711 | })(); 712 | 713 | return ( 714 | 719 | 727 | {title && ( 728 | 740 | {title} 741 | 742 | )} 743 | {titleHintJsx} 744 | 745 | {contents} 746 | {footer && ( 747 | 756 | {footer} 757 | 758 | )} 759 | 760 | ); 761 | } 762 | 763 | /** @return true if the node should be wrapped in text. */ 764 | function isStringishNode(node: React.ReactNode): boolean { 765 | let containsStringChildren = typeof node === "string"; 766 | 767 | React.Children.forEach(node, (child) => { 768 | if (typeof child === "string") { 769 | containsStringChildren = true; 770 | } else if ( 771 | React.isValidElement(child) && 772 | "props" in child && 773 | typeof child.props === "object" && 774 | child.props !== null 775 | ) { 776 | containsStringChildren = isStringishNode(child.props.children as any); 777 | } 778 | }); 779 | return containsStringChildren; 780 | } 781 | 782 | function SymbolView({ 783 | systemImage, 784 | style, 785 | }: { 786 | systemImage?: SystemImageProps | React.ReactNode; 787 | style?: StyleProp; 788 | }) { 789 | if (!systemImage) { 790 | return null; 791 | } 792 | 793 | if (typeof systemImage !== "string" && React.isValidElement(systemImage)) { 794 | return systemImage; 795 | } 796 | 797 | const symbolProps: SystemImageCustomProps = 798 | typeof systemImage === "object" && "name" in systemImage 799 | ? systemImage 800 | : { name: systemImage as unknown as string }; 801 | 802 | return ( 803 | 812 | ); 813 | } 814 | 815 | function LinkChevronIcon({ 816 | href, 817 | systemImage, 818 | }: { 819 | href?: any; 820 | systemImage?: SystemImageProps | React.ReactNode; 821 | }) { 822 | const isHrefExternal = 823 | typeof href === "string" && /^([\w\d_+.-]+:)?\/\//.test(href); 824 | 825 | const size = process.env.EXPO_OS === "ios" ? 14 : 24; 826 | 827 | if (systemImage) { 828 | if (typeof systemImage !== "string") { 829 | if (React.isValidElement(systemImage)) { 830 | return systemImage; 831 | } 832 | return ( 833 | 838 | ); 839 | } 840 | } 841 | 842 | const resolvedName = 843 | typeof systemImage === "string" 844 | ? systemImage 845 | : isHrefExternal 846 | ? "arrow.up.right" 847 | : "chevron.right"; 848 | 849 | return ( 850 | 859 | ); 860 | } 861 | 862 | function Separator() { 863 | return ( 864 | 872 | ); 873 | } 874 | 875 | function mergedStyles(style: ViewStyle | TextStyle, props: any) { 876 | return mergedStyleProp(style, props.style); 877 | } 878 | 879 | export function mergedStyleProp( 880 | style: TStyle, 881 | styleProps?: StyleProp | null 882 | ): StyleProp { 883 | if (styleProps == null) { 884 | return style; 885 | } else if (Array.isArray(styleProps)) { 886 | return [style, ...styleProps]; 887 | } 888 | return [style, styleProps]; 889 | } 890 | 891 | function extractStyle(styleProp: any, key: string) { 892 | if (styleProp == null) { 893 | return undefined; 894 | } else if (Array.isArray(styleProp)) { 895 | return styleProp.find((style) => { 896 | return style[key] != null; 897 | })?.[key]; 898 | } else if (typeof styleProp === "object") { 899 | return styleProp?.[key]; 900 | } 901 | return null; 902 | } 903 | -------------------------------------------------------------------------------- /src/components/ui/IconSymbolFallback.tsx: -------------------------------------------------------------------------------- 1 | // This file is a fallback for using MaterialCommunityIcons on Android and web. 2 | 3 | import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons"; 4 | import MaterialIcons from "@expo/vector-icons/MaterialIcons"; 5 | import { SymbolWeight } from "expo-symbols"; 6 | import React from "react"; 7 | import { OpaqueColorValue, StyleProp, TextStyle } from "react-native"; 8 | 9 | // Add your SFSymbol to MaterialCommunityIcons mappings here. 10 | const MAPPING = { 11 | // See MaterialCommunityIcons here: https://icons.expo.app 12 | // See SF Symbols in the SF Symbols app on Mac. 13 | 14 | "chevron.left.forwardslash.chevron.right": "code-tags", 15 | car: "car-outline", 16 | "car.fill": "car", 17 | "light.beacon.min": "alarm-light-outline", 18 | "airpodspro.chargingcase.wireless.fill": "headset", 19 | "cursorarrow.rays": "cursor-default-click-outline", 20 | "person.text.rectangle": "account-box-outline", 21 | "hand.raised.fill": "hand-front-left", 22 | "0.square": "numeric-0-box-outline", 23 | "0.square.fill": "numeric-0-box", 24 | "1.square": "numeric-1-box-outline", 25 | "1.square.fill": "numeric-1-box", 26 | "2.square": "numeric-2-box-outline", 27 | "2.square.fill": "numeric-2-box", 28 | "3.square": "numeric-3-box-outline", 29 | "3.square.fill": "numeric-3-box", 30 | "4.square": "numeric-4-box-outline", 31 | "4.square.fill": "numeric-4-box", 32 | "5.square": "numeric-5-box-outline", 33 | "5.square.fill": "numeric-5-box", 34 | "6.square": "numeric-6-box-outline", 35 | "6.square.fill": "numeric-6-box", 36 | "7.square": "numeric-7-box-outline", 37 | "7.square.fill": "numeric-7-box", 38 | "8.square": "numeric-8-box-outline", 39 | "8.square.fill": "numeric-8-box", 40 | "9.square": "numeric-9-box-outline", 41 | "9.square.fill": "numeric-9-box", 42 | "10.square": "numeric-10-box-outline", 43 | "10.square.fill": "numeric-10-box", 44 | "app.gift": "gift-outline", 45 | "app.gift.fill": "gift", 46 | person: "account-outline", 47 | 48 | // From: https://github.com/roninoss/icons/blob/05c6ec9eda6c1be50f29577946d7cf778df1501c/packages/icons/src/icon-mapping.ts#L1 49 | "square.and.arrow.up": "tray-arrow-up", 50 | "square.and.arrow.down": "tray-arrow-down", 51 | "square.and.arrow.down.on.square": "arrow-down-bold-box-outline", 52 | "square.and.arrow.down.on.square.fill": "arrow-down-bold-box", 53 | "rectangle.portrait.and.arrow.right": "arrow-right-bold-box-outline", 54 | "rectangle.portrait.and.arrow.right.fill": "arrow-right-bold-box", 55 | pencil: "pencil", 56 | "pencil.circle": "pencil-circle-outline", 57 | "pencil.circle.fill": "pencil-circle", 58 | "pencil.slash": "pencil-off", 59 | "pencil.line": "progress-pencil", 60 | eraser: "eraser", 61 | "eraser.fill": "eraser-variant", 62 | "square.and.pencil": "pencil-box-outline", 63 | "pencil.and.scribble": "draw", 64 | highlighter: "marker", 65 | "pencil.tip": "fountain-pen-tip", 66 | "pencil.tip.crop.circle.badge.plus": "pencil-plus-outline", 67 | "pencil.tip.crop.circle.badge.plus.fill": "pen-plus", 68 | "pencil.tip.crop.circle.badge.minus": "pencil-minus-outline", 69 | "pencil.tip.crop.circle.badge.minus.fill": "pen-minus", 70 | lasso: "lasso", 71 | trash: "trash-can-outline", 72 | "trash.fill": "trash-can", 73 | "trash.circle": "delete-circle-outline", 74 | "trash.circle.fill": "delete-circle", 75 | "trash.slash": "delete-off-outline", 76 | "trash.slash.fill": "delete-off", 77 | "folder.fill": "folder", 78 | "folder.badge.plus": "folder-plus-outline", 79 | "folder.fill.badge.plus": "folder-plus", 80 | "folder.badge.minus": "folder-remove-outline", 81 | "folder.fill.badge.minus": "folder-remove", 82 | "folder.badge.person.crop": "folder-account-outline", 83 | "folder.fill.badge.person.crop": "folder-account", 84 | "folder.badge.gearshape": "folder-cog-outline", 85 | "folder.fill.badge.gearshape": "folder-cog", 86 | paperplane: "send-outline", 87 | "paperplane.fill": "send", 88 | "paperplane.circle": "send-circle-outline", 89 | "paperplane.circle.fill": "send-circle", 90 | tray: "tray", 91 | "tray.fill": "inbox", 92 | "tray.full": "inbox-full-outline", 93 | "tray.full.fill": "inbox-full", 94 | "tray.and.arrow.up": "inbox-arrow-up-outline", 95 | "tray.and.arrow.up.fill": "inbox-arrow-up", 96 | "tray.and.arrow.down": "inbox-arrow-down-outline", 97 | "tray.and.arrow.down.fill": "inbox-arrow-down", 98 | "tray.2": "inbox-multiple-outline", 99 | "tray.2.fill": "inbox-multiple", 100 | externaldrive: "database-outline", 101 | "externaldrive.fill": "database", 102 | "externaldrive.badge.plus": "database-plus-outline", 103 | "externaldrive.fill.badge.plus": "database-plus", 104 | "externaldrive.badge.minus": "database-minus-outline", 105 | "externaldrive.fill.badge.minus": "database-minus", 106 | "externaldrive.badge.checkmark": "database-check-outline", 107 | "externaldrive.fill.badge.checkmark": "database-check", 108 | "externaldrive.badge.xmark": "database-remove-outline", 109 | "externaldrive.fill.badge.xmark": "database-remove", 110 | "externaldrive.badge.exclamationmark": "database-alert-outline", 111 | "externaldrive.fill.badge.exclamationmark": "database-alert", 112 | "externaldrive.badge.timemachine": "database-clock-outline", 113 | "externaldrive.fill.badge.timemachine": "database-clock", 114 | archivebox: "archive-outline", 115 | "archivebox.fill": "archive", 116 | "xmark.bin": "archive-remove-outline", 117 | "xmark.bin.fill": "archive-remove", 118 | "arrow.up.bin": "archive-arrow-up-outline", 119 | "arrow.up.bin.fill": "archive-arrow-up", 120 | doc: "file-document-outline", 121 | "doc.fill": "file-document", 122 | "doc.badge.plus": "file-plus-outline", 123 | "doc.fill.badge.plus": "file-plus", 124 | "arrow.up.doc": "file-upload-outline", 125 | "arrow.up.doc.fill": "file-upload", 126 | "doc.badge.clock": "file-clock-outline", 127 | "doc.badge.clock.fill": "file-clock", 128 | "doc.badge.gearshape": "file-cog-outline", 129 | "doc.badge.gearshape.fill": "file-cog", 130 | "lock.doc": "file-lock-outline", 131 | "lock.doc.fill": "file-lock", 132 | "arrow.down.doc": "file-download-outline", 133 | "arrow.down.doc.fill": "file-download", 134 | "doc.on.doc": "file-document-multiple-outline", 135 | "doc.on.doc.fill": "file-document-multiple", 136 | clipboard: "clipboard-outline", 137 | "clipboard.fill": "clipboard", 138 | "list.clipboard": "clipboard-list-outline", 139 | "list.clipboard.fill": "clipboard-list", 140 | "pencil.and.list.clipboard": "clipboard-edit-outline", 141 | "doc.richtext": "image-text", 142 | "doc.questionmark": "file-question-outline", 143 | "doc.questionmark.fill": "file-question", 144 | "list.bullet.rectangle": "card-bulleted-outline", 145 | "list.bullet.rectangle.fill": "card-bulleted", 146 | "doc.text.magnifyingglass": "file-search-outline", 147 | note: "note-outline", 148 | "note.text": "note-text-outline", 149 | calendar: "calendar-month", 150 | "calendar.badge.plus": "calendar-plus", 151 | "calendar.badge.minus": "calendar-minus", 152 | "calendar.badge.clock": "calendar-clock", 153 | "calendar.badge.exclamationmark": "calendar-alert", 154 | "calendar.badge.checkmark": "calendar-check", 155 | "arrowshape.left": "arrow-left-bold-outline", 156 | "arrowshape.left.fill": "arrow-left-bold", 157 | "arrowshape.left.circle": "arrow-left-bold-circle-outline", 158 | "arrowshape.left.circle.fill": "arrow-left-bold-circle", 159 | "arrowshape.right": "arrow-right-bold-outline", 160 | "arrowshape.right.fill": "arrow-right-bold", 161 | "arrowshape.right.circle": "arrow-right-bold-circle-outline", 162 | "arrowshape.right.circle.fill": "arrow-right-bold-circle", 163 | "arrowshape.up": "arrow-up-bold-outline", 164 | "arrowshape.up.fill": "arrow-up-bold", 165 | "arrowshape.up.circle": "arrow-up-bold-circle-outline", 166 | "arrowshape.up.circle.fill": "arrow-up-bold-circle", 167 | "arrowshape.down": "arrow-down-bold-outline", 168 | "arrowshape.down.fill": "arrow-down-bold", 169 | "arrowshape.down.circle": "arrow-down-bold-circle-outline", 170 | "arrowshape.down.circle.fill": "arrow-down-bold-circle", 171 | "arrowshape.left.arrowshape.right": "arrow-left-right-bold-outline", 172 | "arrowshape.left.arrowshape.right.fill": "arrow-left-right-bold", 173 | "arrowshape.turn.up.left": "arrow-left-top", 174 | "arrowshape.turn.up.left.fill": "arrow-left-top-bold", 175 | "arrowshape.turn.up.right": "arrow-right-top", 176 | "arrowshape.turn.up.right.fill": "arrow-right-top-bold", 177 | book: "book-open-outline", 178 | "book.fill": "book-open", 179 | "books.vertical.fill": "bookshelf", 180 | newspaper: "newspaper", 181 | "newspaper.fill": "newspaper-variant", 182 | bookmark: "bookmark-outline", 183 | "bookmark.fill": "bookmark", 184 | "bookmark.slash": "bookmark-off-outline", 185 | "bookmark.slash.fill": "bookmark-off", 186 | "pencil.and.ruler.fill": "pencil-ruler", 187 | "ruler.fill": "ruler", 188 | paperclip: "paperclip", 189 | link: "link", 190 | "link.badge.plus": "link-plus", 191 | personalhotspot: "vector-link", 192 | "person.circle": "account-circle-outline", 193 | "person.circle.fill": "account-circle", 194 | "person.slash": "account-off-outline", 195 | "person.slash.fill": "account-off", 196 | "person.fill.checkmark": "account-check", 197 | "person.fill.xmark": "account-remove", 198 | "person.fill.questionmark": "account-question", 199 | "person.badge.plus": "account-plus-outline", 200 | "person.badge.minus": "account-minus-outline", 201 | "person.badge.clock": "account-clock-outline", 202 | "person.badge.clock.fill": "account-clock", 203 | "person.badge.key": "account-key-outline", 204 | "person.badge.key.fill": "account-key", 205 | "person.2": "account-multiple-outline", 206 | "person.2.fill": "account-multiple", 207 | "person.wave.2.fill": "account-voice", 208 | "photo.artframe": "image-frame", 209 | "rectangle.checkered": "checkerboard", 210 | "dumbbell.fill": "dumbbell", 211 | sportscourt: "soccer-field", 212 | soccerball: "soccer", 213 | "baseball.fill": "baseball", 214 | "basketball.fill": "basketball", 215 | "football.fill": "football", 216 | "tennis.racket": "tennis", 217 | "hockey.puck.fill": "hockey-puck", 218 | "cricket.ball.fill": "cricket", 219 | "tennisball.fill": "tennis-ball", 220 | "volleyball.fill": "volleyball", 221 | skateboard: "skateboard", 222 | skis: "ski", 223 | surfboard: "surfing", 224 | trophy: "trophy-outline", 225 | "trophy.fill": "trophy", 226 | medal: "medal-outline", 227 | "medal.fill": "medal", 228 | command: "apple-keyboard-command", 229 | space: "keyboard-space", 230 | option: "apple-keyboard-option", 231 | restart: "restart", 232 | zzz: "sleep", 233 | power: "power", 234 | togglepower: "power-cycle", 235 | poweron: "power-on", 236 | poweroff: "power-off", 237 | powersleep: "power-sleep", 238 | clear: "alpha-x-box-outline", 239 | "clear.fill": "alpha-x-box", 240 | "delete.left": "keyboard-backspace", 241 | shift: "apple-keyboard-shift", 242 | capslock: "apple-keyboard-caps", 243 | keyboard: "keyboard-outline", 244 | "keyboard.fill": "keyboard", 245 | "keyboard.badge.ellipsis": "keyboard-settings-outline", 246 | "keyboard.badge.ellipsis.fill": "keyboard-settings", 247 | globe: "web", 248 | network: "access-point-network", 249 | "network.slash": "access-point-network-off", 250 | "sun.min": "weather-sunny", 251 | "sun.max.fill": "white-balance-sunny", 252 | "sun.max.trianglebadge.exclamationmark": "weather-sunny-alert", 253 | "moon.stars": "weather-night", 254 | sparkle: "star-four-points", 255 | cloud: "cloud-outline", 256 | "cloud.fill": "cloud", 257 | "cloud.rain": "weather-rainy", 258 | "cloud.bolt": "weather-lightning", 259 | tornado: "weather-tornado", 260 | "thermometer.sun": "sun-thermometer-outline", 261 | "thermometer.sun.fill": "sun-thermometer", 262 | drop: "water-outline", 263 | "drop.fill": "water", 264 | "drop.circle.fill": "water-circle", 265 | flame: "fire", 266 | "flame.circle": "fire-circle", 267 | umbrella: "umbrella-outline", 268 | "umbrella.fill": "umbrella", 269 | play: "play-outline", 270 | "play.fill": "play", 271 | "play.circle": "play-circle-outline", 272 | "play.circle.fill": "play-circle", 273 | "play.square": "play-box-outline", 274 | "play.square.fill": "play-box", 275 | "play.square.stack": "play-box-multiple-outline", 276 | "play.square.stack.fill": "play-box-multiple", 277 | pause: "pause", 278 | "pause.circle": "pause-circle-outline", 279 | "pause.circle.fill": "pause-circle", 280 | "stop.fill": "stop", 281 | "stop.circle": "stop-circle-outline", 282 | "stop.circle.fill": "stop-circle", 283 | "record.circle": "record-circle-outline", 284 | "record.circle.fill": "record-circle", 285 | "playpause.fill": "play-pause", 286 | backward: "rewind-outline", 287 | "backward.fill": "rewind", 288 | forward: "fast-forward-outline", 289 | "forward.fill": "fast-forward", 290 | "backward.end": "skip-backward-outline", 291 | "backward.end.fill": "skip-backward", 292 | "forward.end": "skip-forward-outline", 293 | "forward.end.fill": "skip-forward", 294 | "backward.frame.fill": "step-backward", 295 | "forward.frame.fill": "step-forward", 296 | shuffle: "shuffle", 297 | repeat: "repeat", 298 | "repeat.1": "repeat-once", 299 | infinity: "infinity", 300 | megaphone: "bullhorn-outline", 301 | "megaphone.fill": "bullhorn", 302 | "speaker.fill": "volume-low", 303 | "speaker.plus.fill": "volume-plus", 304 | "speaker.minus.fill": "volume-minus", 305 | "speaker.slash.fill": "volume-variant-off", 306 | "speaker.wave.1.fill": "volume-medium", 307 | "speaker.wave.3.fill": "volume-high", 308 | "music.note": "music-note", 309 | "music.note.list": "playlist-music", 310 | "music.mic": "microphone-variant", 311 | magnifyingglass: "magnify", 312 | "minus.magnifyingglass": "magnify-minus-outline", 313 | "plus.magnifyingglass": "magnify-plus-outline", 314 | mic: "microphone-outline", 315 | "mic.fill": "microphone", 316 | "mic.slash.fill": "microphone-off", 317 | "mic.fill.badge.plus": "microphone-plus", 318 | circle: "circle-outline", 319 | "circle.fill": "circle", 320 | "circle.slash": "cancel", 321 | target: "target", 322 | square: "square-outline", 323 | "square.fill": "square", 324 | "star.square.on.square": "star-box-multiple-outline", 325 | "star.square.on.square.fill": "star-box-multiple", 326 | "plus.app": "plus-box-outline", 327 | "plus.app.fill": "plus-box", 328 | "app.badge": "checkbox-blank-badge-outline", 329 | "app.badge.fill": "checkbox-blank-badge", 330 | "checkmark.seal": "check-decagram-outline", 331 | "checkmark.seal.fill": "check-decagram", 332 | heart: "heart-outline", 333 | "heart.fill": "heart", 334 | "bolt.heart.fill": "heart-flash", 335 | star: "star-outline", 336 | "star.fill": "star", 337 | shield: "shield-outline", 338 | "shield.fill": "shield", 339 | flag: "flag-outline", 340 | "flag.fill": "flag", 341 | bell: "bell-outline", 342 | "bell.fill": "bell", 343 | tag: "tag-outline", 344 | "tag.fill": "tag", 345 | bolt: "lightning-bolt-outline", 346 | "bolt.fill": "lightning-bolt", 347 | "x.squareroot": "square-root", 348 | "flashlight.on.fill": "flashlight", 349 | "flashlight.off.fill": "flashlight-off", 350 | camera: "camera-outline", 351 | "camera.fill": "camera", 352 | message: "message-outline", 353 | "message.fill": "message", 354 | "plus.message": "message-plus-outline", 355 | "plus.message.fill": "message-plus", 356 | "ellipsis.message": "message-processing-outline", 357 | "ellipsis.message.fill": "message-processing", 358 | "quote.opening": "format-quote-open", 359 | "quote.closing": "format-quote-close", 360 | "quote.bubble": "comment-quote-outline", 361 | "quote.bubble.fill": "comment-quote", 362 | "star.bubble": "message-star-outline", 363 | "star.bubble.fill": "message-star", 364 | "questionmark.bubble": "message-question-outline", 365 | "questionmark.bubble.fill": "message-question", 366 | phone: "phone-outline", 367 | "phone.fill": "phone", 368 | "phone.badge.plus": "phone-plus-outline", 369 | "phone.fill.badge.plus": "phone-plus", 370 | "phone.badge.checkmark": "phone-check-outline", 371 | "phone.fill.badge.checkmark": "phone-check", 372 | video: "video-outline", 373 | "video.fill": "video", 374 | envelope: "email-outline", 375 | "envelope.fill": "email", 376 | "envelope.open.fill": "email-open", 377 | gearshape: "cog-outline", 378 | "gearshape.fill": "cog", 379 | signature: "signature-freehand", 380 | ellipsis: "dots-horizontal", 381 | "ellipsis.circle": "dots-horizontal-circle-outline", 382 | "ellipsis.circle.fill": "dots-horizontal-circle", 383 | bag: "shopping-outline", 384 | "bag.fill": "shopping", 385 | cart: "cart-outline", 386 | "cart.fill": "cart", 387 | basket: "basket-outline", 388 | "basket.fill": "basket", 389 | creditcard: "credit-card-outline", 390 | "creditcard.fill": "credit-card", 391 | crop: "crop", 392 | "crop.rotate": "crop-rotate", 393 | paintbrush: "brush-variant", 394 | level: "spirit-level", 395 | "wrench.adjustable": "wrench-outline", 396 | "wrench.adjustable.fill": "wrench", 397 | "hammer.fill": "hammer", 398 | screwdriver: "screwdriver", 399 | eyedropper: "eyedropper", 400 | "wrench.and.screwdriver.fill": "hammer-screwdriver", 401 | scroll: "script-outline", 402 | "scroll.fill": "script", 403 | printer: "printer-outline", 404 | "printer.fill": "printer", 405 | scanner: "scanner", 406 | theatermasks: "drama-masks", 407 | puzzlepiece: "puzzle-outline", 408 | "puzzlepiece.fill": "puzzle", 409 | house: "home-outline", 410 | "house.fill": "home", 411 | "house.circle": "home-circle-outline", 412 | "house.circle.fill": "home-circle", 413 | storefront: "storefront-outline", 414 | "storefront.fill": "storefront", 415 | lightbulb: "lightbulb-outline", 416 | "lightbulb.fill": "lightbulb", 417 | "lightbulb.slash": "lightbulb-off-outline", 418 | "lightbulb.slash.fill": "lightbulb-off", 419 | "poweroutlet.type.b": "power-socket-us", 420 | powerplug: "power-plug-outline", 421 | "powerplug.fill": "power-plug", 422 | "web.camera.fill": "webcam", 423 | "spigot.fill": "water-pump", 424 | "party.popper.fill": "party-popper", 425 | "balloon.fill": "balloon", 426 | fireworks: "firework", 427 | building: "office-building-outline", 428 | "building.fill": "office-building", 429 | "building.2": "city-variant-outline", 430 | "building.2.fill": "city-variant", 431 | lock: "lock-outline", 432 | "lock.fill": "lock", 433 | "lock.shield": "shield-lock-outline", 434 | "lock.shield.fill": "shield-lock", 435 | "lock.slash": "lock-off-outline", 436 | "lock.slash.fill": "lock-off", 437 | "exclamationmark.lock": "lock-alert-outline", 438 | "exclamationmark.lock.fill": "lock-alert", 439 | "lock.badge.clock.fill": "lock-clock", 440 | "lock.open": "lock-open", 441 | "lock.open.fill": "lock-open-outline", 442 | "lock.open.trianglebadge.exclamationmark": "lock-open-alert", 443 | "lock.open.trianglebadge.exclamationmark.fill": "lock-open-alert-outline", 444 | "lock.rotation": "lock-reset", 445 | key: "key-outline", 446 | "key.fill": "key", 447 | wifi: "wifi", 448 | "wifi.slash": "wifi-off", 449 | "wifi.exclamationmark": "wifi-alert", 450 | pin: "pin-outline", 451 | "pin.fill": "pin", 452 | "pin.slash": "pin-off-outline", 453 | "pin.slash.fill": "pin-off", 454 | mappin: "map-marker-outline", 455 | "mappin.circle": "map-marker-radius-outline", 456 | "mappin.circle.fill": "map-marker-radius", 457 | "mappin.slash": "map-marker-off-outline", 458 | map: "map-outline", 459 | "map.fill": "map", 460 | display: "monitor", 461 | "lock.display": "monitor-lock", 462 | "display.2": "monitor-multiple", 463 | "server.rack": "server", 464 | laptopcomputer: "laptop", 465 | "laptopcomputer.slash": "laptop-off", 466 | smartphone: "cellphone", 467 | headphones: "headphones", 468 | "play.tv.fill": "youtube-tv", 469 | horn: "bullhorn-variant-outline", 470 | "horn.fill": "bullhorn-variant", 471 | "bandage.fill": "bandage", 472 | crown: "crown-outline", 473 | "crown.fill": "crown", 474 | "film.fill": "filmstrip", 475 | "film.stack.fill": "filmstrip-box-multiple", 476 | movieclapper: "movie-open-outline", 477 | "movieclapper.fill": "movie-open", 478 | ticket: "ticket-confirmation-outline", 479 | "ticket.fill": "ticket-confirmation", 480 | eye: "eye-outline", 481 | "eye.fill": "eye", 482 | "eye.slash": "eye-off-outline", 483 | "eye.slash.fill": "eye-off", 484 | brain: "brain", 485 | qrcode: "qrcode", 486 | barcode: "barcode", 487 | photo: "image-outline", 488 | "photo.fill": "image", 489 | "photo.badge.plus.fill": "image-plus", 490 | "photo.stack": "image-multiple-outline", 491 | "photo.stack.fill": "image-multiple", 492 | clock: "clock-outline", 493 | "clock.fill": "clock", 494 | "clock.badge.checkmark": "clock-check-outline", 495 | "clock.badge.checkmark.fill": "clock-check", 496 | "clock.badge.xmark": "clock-remove-outline", 497 | "clock.badge.xmark.fill": "clock-remove", 498 | "clock.badge.exclamationmark": "clock-alert-outline", 499 | "clock.badge.exclamationmark.fill": "clock-alert", 500 | alarm: "alarm", 501 | stopwatch: "timer-outline", 502 | "stopwatch.fill": "timer", 503 | "chart.xyaxis.line": "chart-timeline-variant", 504 | timer: "camera-timer", 505 | gamecontroller: "controller-classic-outline", 506 | "gamecontroller.fill": "controller-classic", 507 | "playstation.logo": "sony-playstation", 508 | "xbox.logo": "microsoft-xbox", 509 | paintpalette: "palette-outline", 510 | "paintpalette.fill": "palette", 511 | swatchpalette: "palette-swatch-outline", 512 | "swatchpalette.fill": "palette-swatch", 513 | "fork.knife": "silverware-fork-knife", 514 | "chart.bar": "chart-box-outline", 515 | "chart.bar.fill": "chart-box", 516 | cellularbars: "signal-cellular-3", 517 | "chart.pie.fill": "chart-pie", 518 | "sdcard.fill": "sd", 519 | atom: "atom", 520 | angle: "angle-acute", 521 | "compass.drawing": "math-compass", 522 | "globe.desk": "globe-model", 523 | gift: "gift-outline", 524 | "gift.fill": "gift", 525 | banknote: "cash", 526 | grid: "grid", 527 | checklist: "format-list-checks", 528 | "list.bullet": "format-list-bulleted", 529 | "line.3.horizontal": "reorder-horizontal", 530 | bold: "format-bold", 531 | italic: "format-italic", 532 | underline: "format-underline", 533 | strikethrough: "format-strikethrough", 534 | percent: "percent", 535 | "info.circle": "information-outline", 536 | "info.circle.fill": "information", 537 | questionmark: "help", 538 | exclamationmark: "exclamation", 539 | plus: "plus", 540 | minus: "minus", 541 | plusminus: "plus-minus", 542 | multiply: "close", 543 | divide: "division", 544 | equal: "equal", 545 | lessthan: "less-than", 546 | greaterthan: "greater-than", 547 | parentheses: "code-parentheses", 548 | curlybraces: "code-braces", 549 | "ellipsis.curlybraces": "code-json", 550 | xmark: "close-thick", 551 | "xmark.circle": "close-circle-outline", 552 | "xmark.circle.fill": "close-circle", 553 | "xmark.square": "close-box-outline", 554 | "xmark.square.fill": "close-box", 555 | "xmark.shield": "shield-remove-outline", 556 | "xmark.shield.fill": "shield-remove", 557 | "xmark.octagon": "close-octagon-outline", 558 | "xmark.octagon.fill": "close-octagon", 559 | checkmark: "check", 560 | "checkmark.circle": "check-circle-outline", 561 | "checkmark.circle.fill": "check-circle", 562 | "checkmark.shield": "shield-check-outline", 563 | "checkmark.shield.fill": "shield-check", 564 | "chevron.left": "chevron-left", 565 | "chevron.left.circle": "chevron-left-circle-outline", 566 | "chevron.left.circle.fill": "chevron-left-circle", 567 | "chevron.left.square": "chevron-left-box-outline", 568 | "chevron.left.square.fill": "chevron-left-box", 569 | "chevron.right": "chevron-right", 570 | "chevron.right.circle": "chevron-right-circle-outline", 571 | "chevron.right.circle.fill": "chevron-right-circle", 572 | "chevron.right.square": "chevron-right-box-outline", 573 | "chevron.right.square.fill": "chevron-right-box", 574 | "chevron.left.2": "chevron-double-left", 575 | "chevron.right.2": "chevron-double-right", 576 | "chevron.up": "chevron-up", 577 | "chevron.up.circle": "chevron-up-circle-outline", 578 | "chevron.up.circle.fill": "chevron-up-circle", 579 | "chevron.up.square": "chevron-up-box-outline", 580 | "chevron.up.square.fill": "chevron-up-box", 581 | "chevron.down": "chevron-down", 582 | "chevron.down.circle": "chevron-down-circle-outline", 583 | "chevron.down.circle.fill": "chevron-down-circle", 584 | "chevron.down.square": "chevron-down-box-outline", 585 | "chevron.down.square.fill": "chevron-down-box", 586 | "chevron.up.chevron.down": "unfold-more-horizontal", 587 | "arrow.left": "arrow-left", 588 | "arrow.left.circle": "arrow-left-circle-outline", 589 | "arrow.left.circle.fill": "arrow-left-circle", 590 | "arrow.left.square.fill": "arrow-left-box", 591 | "arrow.right": "arrow-right", 592 | "arrow.right.circle": "arrow-right-circle-outline", 593 | "arrow.right.circle.fill": "arrow-right-circle", 594 | "arrow.right.square.fill": "arrow-right-box", 595 | "arrow.up": "arrow-up", 596 | "arrow.up.circle": "arrow-up-circle-outline", 597 | "arrow.up.circle.fill": "arrow-up-circle", 598 | "arrow.up.left": "arrow-top-left", 599 | "arrow.up.right": "arrow-top-right", 600 | "arrow.up.square.fill": "arrow-up-box", 601 | "arrow.down": "arrow-down", 602 | "arrow.down.circle": "arrow-down-circle-outline", 603 | "arrow.down.circle.fill": "arrow-down-circle", 604 | "arrow.down.square.fill": "arrow-down-box", 605 | "arrow.down.left": "arrow-bottom-left", 606 | "arrow.down.right": "arrow-bottom-right", 607 | asterisk: "asterisk", 608 | "apple.logo": "apple", 609 | } as Partial< 610 | Record< 611 | import("expo-symbols").SymbolViewProps["name"], 612 | React.ComponentProps["name"] 613 | > 614 | >; 615 | 616 | const MATERIAL_MAPPING = { 617 | "photo.on.rectangle": "photo-library", 618 | "person.fill.badge.plus": "person-add", 619 | iphone: "smartphone", 620 | } as Partial< 621 | Record< 622 | import("expo-symbols").SymbolViewProps["name"], 623 | React.ComponentProps["name"] 624 | > 625 | >; 626 | 627 | export type IconSymbolName = keyof typeof MAPPING; 628 | 629 | /** 630 | * An icon component that uses native SFSymbols on iOS, and MaterialCommunityIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage. 631 | * 632 | * Icon `name`s are based on SFSymbols and require manual mapping to MaterialCommunityIcons. 633 | */ 634 | export function IconSymbolMaterial({ 635 | name, 636 | size = 24, 637 | color, 638 | style, 639 | }: { 640 | name: IconSymbolName; 641 | size?: number; 642 | color: string | OpaqueColorValue; 643 | style?: StyleProp; 644 | weight?: SymbolWeight; 645 | 646 | /** iOS-only */ 647 | animationSpec?: import("expo-symbols").SymbolViewProps["animationSpec"]; 648 | }) { 649 | const materialCommunityIcon = MAPPING[name]; 650 | if (materialCommunityIcon) { 651 | return ( 652 | 658 | ); 659 | } 660 | return ( 661 | 667 | ); 668 | } 669 | --------------------------------------------------------------------------------