diff --git a/apps/mobile/.eslintignore b/apps/mobile/.eslintignore new file mode 100644 index 0000000..408b6c8 --- /dev/null +++ b/apps/mobile/.eslintignore @@ -0,0 +1,8 @@ +node_modules +ios +android +.expo +.vscode +ignite/ignite.json +package.json +.eslintignore diff --git a/apps/mobile/.eslintrc.js b/apps/mobile/.eslintrc.js new file mode 100644 index 0000000..fc40f93 --- /dev/null +++ b/apps/mobile/.eslintrc.js @@ -0,0 +1,59 @@ +// https://docs.expo.dev/guides/using-eslint/ +module.exports = { + extends: [ + "standard", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react-native/all", + // `expo` must come after `standard` or its globals configuration will be overridden + "expo", + // `jsx-runtime` must come after `expo` or it will be overridden + "plugin:react/jsx-runtime", + "prettier", + ], + plugins: ["reactotron", "prettier"], + rules: { + "prettier/prettier": "error", + // typescript-eslint + "@typescript-eslint/array-type": 0, + "@typescript-eslint/ban-ts-comment": 0, + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/no-require-imports": 0, + "@typescript-eslint/no-empty-object-type": 0, + // eslint + "no-use-before-define": 0, + "no-restricted-imports": [ + "error", + { + paths: [ + // Prefer named exports from 'react' instead of importing `React` + { + name: "react", + importNames: ["default"], + message: "Import named exports from 'react' instead.", + }, + ], + }, + ], + // react + "react/prop-types": 0, + // react-native + "react-native/no-raw-text": 0, + // reactotron + "reactotron/no-tron-in-production": "error", + // eslint-config-standard overrides + "comma-dangle": 0, + "no-global-assign": 0, + "quotes": 0, + "space-before-function-paren": 0, + "camelcase": 0, + }, +} diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..3a54480 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,92 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +ios/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +*.keystore +!debug.keystore + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ignite-specific items below +# You can safely replace everything above this comment with whatever is +# in the default .gitignore generated by React-Native CLI + +# VS Code +.vscode + +# Expo +.expo/* +bin/Exponent.app +/android +/ios +expo-env.d.ts + +## Secrets +npm-debug.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +*.orig.* +web-build/ + +# Configurations +!env.js + +/coverage + +# Yarn +.yarn/* diff --git a/apps/mobile/.prettierignore b/apps/mobile/.prettierignore new file mode 100644 index 0000000..9acad61 --- /dev/null +++ b/apps/mobile/.prettierignore @@ -0,0 +1,9 @@ +node_modules +ios +android +.expo +.vscode +ignite/ignite.json +package.json +.eslintignore +app/app.tsx diff --git a/apps/mobile/.prettierrc b/apps/mobile/.prettierrc new file mode 100644 index 0000000..0ec1123 --- /dev/null +++ b/apps/mobile/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": false, + "trailingComma": "all", + "quoteProps": "consistent", + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrder": ["^(react|react-native)(/(.*))?$", "", "^@/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true +} diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..ddd8e77 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,77 @@ +# Welcome to your new ignited app! + +> The latest and greatest boilerplate for Infinite Red opinions + +This is the boilerplate that [Infinite Red](https://infinite.red) uses as a way to test bleeding-edge changes to our React Native stack. + +- [Quick start documentation](https://github.com/infinitered/ignite/blob/master/docs/boilerplate/Boilerplate.md) +- [Full documentation](https://github.com/infinitered/ignite/blob/master/docs/README.md) + +## Getting Started + +```bash +yarn +yarn start +``` + +To make things work on your local simulator, or on your phone, you need first to [run `eas build`](https://github.com/infinitered/ignite/blob/master/docs/expo/EAS.md). We have many shortcuts on `package.json` to make it easier: + +```bash +yarn build:ios:sim # build for ios simulator +yarn build:ios:dev # build for ios device +yarn build:ios:prod # build for ios device +``` + +### `./assets` directory + +This directory is designed to organize and store various assets, making it easy for you to manage and use them in your application. The assets are further categorized into subdirectories, including `icons` and `images`: + +```tree +assets +├── icons +└── images +``` + +**icons** +This is where your icon assets will live. These icons can be used for buttons, navigation elements, or any other UI components. The recommended format for icons is PNG, but other formats can be used as well. + +Ignite comes with a built-in `Icon` component. You can find detailed usage instructions in the [docs](https://github.com/infinitered/ignite/blob/master/docs/boilerplate/app/components/Icon.md). + +**images** +This is where your images will live, such as background images, logos, or any other graphics. You can use various formats such as PNG, JPEG, or GIF for your images. + +Another valuable built-in component within Ignite is the `AutoImage` component. You can find detailed usage instructions in the [docs](https://github.com/infinitered/ignite/blob/master/docs/Components-AutoImage.md). + +How to use your `icon` or `image` assets: + +```typescript +import { Image } from 'react-native'; + +const MyComponent = () => { + return ( + + ); +}; +``` + +## Running Maestro end-to-end tests + +Follow our [Maestro Setup](https://ignitecookbook.com/docs/recipes/MaestroSetup) recipe. + +## Next Steps + +### Ignite Cookbook + +[Ignite Cookbook](https://ignitecookbook.com/) is an easy way for developers to browse and share code snippets (or “recipes”) that actually work. + +### Upgrade Ignite boilerplate + +Read our [Upgrade Guide](https://ignitecookbook.com/docs/recipes/UpdatingIgnite) to learn how to upgrade your Ignite project. + +## Community + +⭐️ Help us out by [starring on GitHub](https://github.com/infinitered/ignite), filing bug reports in [issues](https://github.com/infinitered/ignite/issues) or [ask questions](https://github.com/infinitered/ignite/discussions). + +💬 Join us on [Slack](https://join.slack.com/t/infiniteredcommunity/shared_invite/zt-1f137np4h-zPTq_CbaRFUOR_glUFs2UA) to discuss. + +📰 Make our Editor-in-chief happy by [reading the React Native Newsletter](https://reactnativenewsletter.com/). diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts new file mode 100644 index 0000000..c4f57fd --- /dev/null +++ b/apps/mobile/app.config.ts @@ -0,0 +1,22 @@ +import { ConfigContext, ExpoConfig } from "@expo/config" + +/** + * Use ts-node here so we can use TypeScript for our Config Plugins + * and not have to compile them to JavaScript + */ +require("ts-node/register") + +/** + * @param config ExpoConfig coming from the static config app.json if it exists + * + * You can read more about Expo's Configuration Resolution Rules here: + * https://docs.expo.dev/workflow/configuration/#configuration-resolution-rules + */ +module.exports = ({ config }: ConfigContext): Partial => { + const existingPlugins = config.plugins ?? [] + + return { + ...config, + plugins: [...existingPlugins, require("./plugins/withSplashScreen").withSplashScreen], + } +} diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..5a8213e --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,69 @@ +{ + "name": "latent-booking", + "displayName": "Latent Booking", + "expo": { + "name": "Latent Booking", + "slug": "latent-booking", + "scheme": "latent-booking", + "version": "1.0.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "icon": "./assets/images/app-icon-all.png", + "updates": { + "fallbackToCacheTimeout": 0 + }, + "newArchEnabled": false, + "jsEngine": "hermes", + "assetBundlePatterns": [ + "**/*" + ], + "android": { + "icon": "./assets/images/app-icon-android-legacy.png", + "package": "com.latentbooking", + "adaptiveIcon": { + "foregroundImage": "./assets/images/app-icon-android-adaptive-foreground.png", + "backgroundImage": "./assets/images/app-icon-android-adaptive-background.png" + }, + "splash": { + "backgroundColor": "#0A0A0A" + } + }, + "ios": { + "icon": "./assets/images/app-icon-ios.png", + "supportsTablet": true, + "bundleIdentifier": "com.latentbooking", + "splash": { + "backgroundColor": "#0A0A0A" + } + }, + "web": { + "favicon": "./assets/images/app-icon-web-favicon.png", + "bundler": "metro", + "splash": { + "backgroundColor": "#0A0A0A" + } + }, + "plugins": [ + "expo-localization", + "expo-font", + [ + "expo-splash-screen", + { + "image": "./assets/images/app-icon-android-adaptive-foreground.png", + "imageWidth": 300, + "resizeMode": "contain", + "backgroundColor": "#0A0A0A" + } + ] + ], + "experiments": { + "tsconfigPaths": true + }, + "splash": { + "backgroundColor": "#0A0A0A" + } + }, + "ignite": { + "version": "10.1.4" + } +} diff --git a/apps/mobile/app/app.tsx b/apps/mobile/app/app.tsx new file mode 100644 index 0000000..1b81951 --- /dev/null +++ b/apps/mobile/app/app.tsx @@ -0,0 +1,114 @@ +/* eslint-disable import/first */ +/** + * Welcome to the main entry point of the app. In this file, we'll + * be kicking off our app. + * + * Most of this file is boilerplate and you shouldn't need to modify + * it very often. But take some time to look through and understand + * what is going on here. + * + * The app navigation resides in ./app/navigators, so head over there + * if you're interested in adding screens and navigators. + */ +if (__DEV__) { + // Load Reactotron in development only. + // Note that you must be using metro's `inlineRequires` for this to work. + // If you turn it off in metro.config.js, you'll have to manually import it. + require("./devtools/ReactotronConfig.ts") +} +import "./utils/gestureHandler" +import { initI18n } from "./i18n" +import "./utils/ignoreWarnings" +import { useFonts } from "expo-font" +import { useEffect, useState } from "react" +import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context" +import * as Linking from "expo-linking" +import * as SplashScreen from "expo-splash-screen" +import { AppNavigator } from "./navigators" +import { ErrorBoundary } from "./screens/ErrorScreen/ErrorBoundary" +// import * as storage from "./utils/storage" +import { customFontsToLoad } from "./theme" +import Config from "./config" +import { KeyboardProvider } from "react-native-keyboard-controller" +import { loadDateFnsLocale } from "./utils/formatDate" + +export const NAVIGATION_PERSISTENCE_KEY = "NAVIGATION_STATE" + +// Web linking configuration +const prefix = Linking.createURL("/") +const config = { + screens: { + Login: { + path: "", + }, + Welcome: "welcome", + Demo: { + screens: { + DemoShowroom: { + path: "showroom/:queryIndex?/:itemIndex?", + }, + DemoDebug: "debug", + DemoPodcastList: "podcast", + DemoCommunity: "community", + }, + }, + }, +} + +/** + * This is the root component of our app. + * @param {AppProps} props - The props for the `App` component. + * @returns {JSX.Element} The rendered `App` component. + */ +export function App() { + // const { + // initialNavigationState, + // onNavigationStateChange, + // isRestored: isNavigationStateRestored, + // } = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY) + + const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad) + const [isI18nInitialized, setIsI18nInitialized] = useState(false) + + useEffect(() => { + initI18n() + .then(() => setIsI18nInitialized(true)) + .then(() => loadDateFnsLocale()) + }, []) + + useEffect(() => { + // If your initialization scripts run very fast, it's good to show the splash screen for just a bit longer to prevent flicker. + // Slightly delaying splash screen hiding for better UX; can be customized or removed as needed, + setTimeout(SplashScreen.hideAsync, 500) + }, []) + + // Before we show the app, we have to wait for our state to be ready. + // In the meantime, don't render anything. This will be the background + // color set in native by rootView's background color. + // In iOS: application:didFinishLaunchingWithOptions: + // In Android: https://stackoverflow.com/a/45838109/204044 + // You can replace with your own loading component if you wish. + if (!isI18nInitialized || (!areFontsLoaded && !fontLoadError)) { + return null + } + + const linking = { + prefixes: [prefix], + config, + } + + // otherwise, we're ready to render the app + return ( + + + + + + + + ) +} diff --git a/apps/mobile/app/components/AutoImage.tsx b/apps/mobile/app/components/AutoImage.tsx new file mode 100644 index 0000000..d4dbd5b --- /dev/null +++ b/apps/mobile/app/components/AutoImage.tsx @@ -0,0 +1,89 @@ +import { useLayoutEffect, useState } from "react" +import { Image, ImageProps, ImageURISource, PixelRatio, Platform } from "react-native" + +export interface AutoImageProps extends ImageProps { + /** + * How wide should the image be? + */ + maxWidth?: number + /** + * How tall should the image be? + */ + maxHeight?: number + headers?: { + [key: string]: string + } +} + +/** + * A hook that will return the scaled dimensions of an image based on the + * provided dimensions' aspect ratio. If no desired dimensions are provided, + * it will return the original dimensions of the remote image. + * + * How is this different from `resizeMode: 'contain'`? Firstly, you can + * specify only one side's size (not both). Secondly, the image will scale to fit + * the desired dimensions instead of just being contained within its image-container. + * @param {number} remoteUri - The URI of the remote image. + * @param {number} dimensions - The desired dimensions of the image. If not provided, the original dimensions will be returned. + * @returns {[number, number]} - The scaled dimensions of the image. + */ +export function useAutoImage( + remoteUri: string, + headers?: { + [key: string]: string + }, + dimensions?: [maxWidth?: number, maxHeight?: number], +): [width: number, height: number] { + const [[remoteWidth, remoteHeight], setRemoteImageDimensions] = useState([0, 0]) + const remoteAspectRatio = remoteWidth / remoteHeight + const [maxWidth, maxHeight] = dimensions ?? [] + + useLayoutEffect(() => { + if (!remoteUri) return + + if (!headers) { + Image.getSize(remoteUri, (w, h) => setRemoteImageDimensions([w, h])) + } else { + Image.getSizeWithHeaders(remoteUri, headers, (w, h) => setRemoteImageDimensions([w, h])) + } + }, [remoteUri, headers]) + + if (Number.isNaN(remoteAspectRatio)) return [0, 0] + + if (maxWidth && maxHeight) { + const aspectRatio = Math.min(maxWidth / remoteWidth, maxHeight / remoteHeight) + return [ + PixelRatio.roundToNearestPixel(remoteWidth * aspectRatio), + PixelRatio.roundToNearestPixel(remoteHeight * aspectRatio), + ] + } else if (maxWidth) { + return [maxWidth, PixelRatio.roundToNearestPixel(maxWidth / remoteAspectRatio)] + } else if (maxHeight) { + return [PixelRatio.roundToNearestPixel(maxHeight * remoteAspectRatio), maxHeight] + } else { + return [remoteWidth, remoteHeight] + } +} + +/** + * An Image component that automatically sizes a remote or data-uri image. + * @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/AutoImage/} + * @param {AutoImageProps} props - The props for the `AutoImage` component. + * @returns {JSX.Element} The rendered `AutoImage` component. + */ +export function AutoImage(props: AutoImageProps) { + const { maxWidth, maxHeight, ...ImageProps } = props + const source = props.source as ImageURISource + const headers = source?.headers + + const [width, height] = useAutoImage( + Platform.select({ + web: (source?.uri as string) ?? (source as string), + default: source?.uri as string, + }), + headers, + [maxWidth, maxHeight], + ) + + return +} diff --git a/apps/mobile/app/components/Button.tsx b/apps/mobile/app/components/Button.tsx new file mode 100644 index 0000000..a255fbd --- /dev/null +++ b/apps/mobile/app/components/Button.tsx @@ -0,0 +1,248 @@ +import { ComponentType } from "react" +import { + Pressable, + PressableProps, + PressableStateCallbackType, + StyleProp, + TextStyle, + ViewStyle, +} from "react-native" + +import type { ThemedStyle, ThemedStyleArray } from "@/theme" +import { useAppTheme } from "@/utils/useAppTheme" + +import { $styles } from "../theme" +import { Text, TextProps } from "./Text" + +type Presets = "default" | "filled" | "reversed" + +export interface ButtonAccessoryProps { + style: StyleProp + pressableState: PressableStateCallbackType + disabled?: boolean +} + +export interface ButtonProps extends PressableProps { + /** + * Text which is looked up via i18n. + */ + tx?: TextProps["tx"] + /** + * The text to display if not using `tx` or nested components. + */ + text?: TextProps["text"] + /** + * Optional options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + txOptions?: TextProps["txOptions"] + /** + * An optional style override useful for padding & margin. + */ + style?: StyleProp + /** + * An optional style override for the "pressed" state. + */ + pressedStyle?: StyleProp + /** + * An optional style override for the button text. + */ + textStyle?: StyleProp + /** + * An optional style override for the button text when in the "pressed" state. + */ + pressedTextStyle?: StyleProp + /** + * An optional style override for the button text when in the "disabled" state. + */ + disabledTextStyle?: StyleProp + /** + * One of the different types of button presets. + */ + preset?: Presets + /** + * An optional component to render on the right side of the text. + * Example: `RightAccessory={(props) => }` + */ + RightAccessory?: ComponentType + /** + * An optional component to render on the left side of the text. + * Example: `LeftAccessory={(props) => }` + */ + LeftAccessory?: ComponentType + /** + * Children components. + */ + children?: React.ReactNode + /** + * disabled prop, accessed directly for declarative styling reasons. + * https://reactnative.dev/docs/pressable#disabled + */ + disabled?: boolean + /** + * An optional style override for the disabled state + */ + disabledStyle?: StyleProp +} + +/** + * A component that allows users to take actions and make choices. + * Wraps the Text component with a Pressable component. + * @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Button/} + * @param {ButtonProps} props - The props for the `Button` component. + * @returns {JSX.Element} The rendered `Button` component. + * @example + *