diff --git a/boilerplate/README.md b/boilerplate/README.md index ddd8e779c..de7442768 100644 --- a/boilerplate/README.md +++ b/boilerplate/README.md @@ -1,77 +1,91 @@ -# Welcome to your new ignited app! +# Welcome to the Spacebox Application! -> The latest and greatest boilerplate for Infinite Red opinions +## Overview -This is the boilerplate that [Infinite Red](https://infinite.red) uses as a way to test bleeding-edge changes to our React Native stack. +The Spacebox application is a mobile application that allows users to manage their items. It involves two screens: -- [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) +- The Login screen, where users can log in to their account. +- The Home screen, where users can view their items, add new items, and remove items. -## Getting Started +Optionally, it involves a backend application that allows users to manage their items using a REST API. The backend application is built using Node.js and Express, and it uses PostgreSQL as the database. The backend application is exposed to the internet using ngrok, which allows users to access it from anywhere. The backend application can be disabled through the configuration file in app/config. + +For ease of use, the user id is hardcoded in the application and the application doesn't implement any authentication mechanism (e.g JWT, OAuth, etc.). + +The mobile application is built using React Native and Expo and has been tested **on iOS only.** + +The application is translated into English and Traditional Chinese. The language is set based on the device's language settings. If the device's language is not supported, the application will default to English. + +## Setup the application + +Make sure you run the Node.js version 22.0 or higher. +You can check your Node.js version by running the following command: ```bash -yarn -yarn start +node -v ``` -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: +If you don't have Node.js installed, you can download it from the official website: [Node.js](https://nodejs.org/) +If you have Node.js installed, you can run the following command to install the dependencies: ```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 +npm install ``` -### `./assets` directory +### Setup the backend application -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`: +In order to run the backend application in the folder `backend-app-spacebox`, you need to have the following dependencies installed: -```tree -assets -├── icons -└── images -``` +- Docker compose +- Node.js -**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. +Once you have the dependencies installed, you can run the following command to start the backend application: -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. +```bash +docker-compose up -d +``` -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). +The backend application will be running on port 3002 locally and the database will be running on port 5432 locally. +An ngrok server is configured to expose the backend application to the internet. To grab the ngrok URL, you can open your browser and go to the following URL: http://localhost:4040/inspect/http -How to use your `icon` or `image` assets: +### Setup the react-native expo application -```typescript -import { Image } from 'react-native'; +Once the backend application is running, you can run the following command to start the react-native expo application in the root folder of the project: -const MyComponent = () => { - return ( - - ); -}; +```bash +npm install ``` -## Running Maestro end-to-end tests +Set the backend configuration url in app/config if you wanna use the backend API previously started. -Follow our [Maestro Setup](https://ignitecookbook.com/docs/recipes/MaestroSetup) recipe. +```bash +`API_URL` should be set to the ngrok URL you got from the previous step. +`USE_API_ITEMS` should be set to `true` to use the backend API. +``` -## Next Steps +To run the application on the IOS simulator, you can run the following command: -### Ignite Cookbook +```bash +npm run ios +``` -[Ignite Cookbook](https://ignitecookbook.com/) is an easy way for developers to browse and share code snippets (or “recipes”) that actually work. +If you just want to run the current development build without changing the native code, you can run the following command: + +```bash +npm run start:ios +``` -### Upgrade Ignite boilerplate +## Run Tests -Read our [Upgrade Guide](https://ignitecookbook.com/docs/recipes/UpdatingIgnite) to learn how to upgrade your Ignite project. +To run the tests, you can run the following command: -## Community +```bash +npm test +``` -⭐️ 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). +To run the tests in watch mode, you can run the following command: -💬 Join us on [Slack](https://join.slack.com/t/infiniteredcommunity/shared_invite/zt-1f137np4h-zPTq_CbaRFUOR_glUFs2UA) to discuss. +```bash +npm test:watch +``` -📰 Make our Editor-in-chief happy by [reading the React Native Newsletter](https://reactnativenewsletter.com/). +Note: The tests should be run with the config `USE_API_ITEMS` set to `false` in order to run the tests without the backend API. diff --git a/boilerplate/app.json b/boilerplate/app.json index 9aa54afce..aa414baf0 100644 --- a/boilerplate/app.json +++ b/boilerplate/app.json @@ -1,39 +1,28 @@ { - "name": "HelloWorld", - "displayName": "HelloWorld", + "name": "Spacebox", + "displayName": "Spacebox", "expo": { - "name": "HelloWorld", - "slug": "HelloWorld", - "scheme": "helloworld", + "name": "Spacebox", + "slug": "Spacebox", + "scheme": "spacebox", "version": "1.0.0", "orientation": "portrait", + "backgroundColor": "#000000", "userInterfaceStyle": "automatic", "icon": "./assets/images/app-icon-all.png", "updates": { "fallbackToCacheTimeout": 0 }, - "newArchEnabled": false, + "newArchEnabled": true, "jsEngine": "hermes", - "assetBundlePatterns": [ - "**/*" - ], - "android": { - "icon": "./assets/images/app-icon-android-legacy.png", - "package": "com.helloworld", - "adaptiveIcon": { - "foregroundImage": "./assets/images/app-icon-android-adaptive-foreground.png", - "backgroundImage": "./assets/images/app-icon-android-adaptive-background.png" - }, - "allowBackup": false - }, + "assetBundlePatterns": ["**/*"], "ios": { - "icon": "./assets/images/app-icon-ios.png", + "icon": "./assets/images/app-icon-all.png", "supportsTablet": true, - "bundleIdentifier": "com.helloworld" - }, - "web": { - "favicon": "./assets/images/app-icon-web-favicon.png", - "bundler": "metro" + "bundleIdentifier": "com.spacebox", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "plugins": [ "expo-localization", @@ -41,18 +30,23 @@ [ "expo-splash-screen", { - "image": "./assets/images/app-icon-android-adaptive-foreground.png", + "image": "./assets/images/spacebox.png", "imageWidth": 300, "resizeMode": "contain", - "backgroundColor": "#191015" + "backgroundColor": "#FFFFFF" } ] ], "experiments": { "tsconfigPaths": true + }, + "extra": { + "eas": { + "projectId": "b98c7724-a87f-4475-a75d-d70bc3755e47" + } + }, + "android": { + "package": "com.spacebox" } - }, - "ignite": { - "version": "UNKNOWN" } } diff --git a/boilerplate/app/app.tsx b/boilerplate/app/app.tsx index e43de749c..e064a9305 100644 --- a/boilerplate/app/app.tsx +++ b/boilerplate/app/app.tsx @@ -14,47 +14,42 @@ 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") + 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 { useInitialRootStore } from "./models" // @mst remove-current-line -import { AppNavigator, useNavigationPersistence } 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" +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 { useInitialRootStore } from './models'; +import { AppNavigator, useNavigationPersistence } 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'; +import './utils/gestureHandler'; +import { initI18n } from './i18n'; +import './utils/ignoreWarnings'; + +export const NAVIGATION_PERSISTENCE_KEY = 'NAVIGATION_STATE'; // Web linking configuration -const prefix = Linking.createURL("/") +const prefix = Linking.createURL('/'); const config = { screens: { Login: { - path: "", - }, - Welcome: "welcome", - Demo: { - screens: { - DemoShowroom: { - path: "showroom/:queryIndex?/:itemIndex?", - }, - DemoDebug: "debug", - DemoPodcastList: "podcast", - DemoCommunity: "community", - }, + path: '', }, + Home: 'home', }, -} +}; /** * This is the root component of our app. @@ -66,48 +61,38 @@ export function App() { initialNavigationState, onNavigationStateChange, isRestored: isNavigationStateRestored, - } = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY) + } = useNavigationPersistence(storage, NAVIGATION_PERSISTENCE_KEY); - const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad) - const [isI18nInitialized, setIsI18nInitialized] = useState(false) + const [areFontsLoaded, fontLoadError] = useFonts(customFontsToLoad); + const [isI18nInitialized, setIsI18nInitialized] = useState(false); useEffect(() => { initI18n() .then(() => setIsI18nInitialized(true)) - .then(() => loadDateFnsLocale()) - }, []) + .then(() => loadDateFnsLocale()); + }, []); - // @mst replace-next-line useEffect(() => { const { rehydrated } = useInitialRootStore(() => { - // @mst replace-next-line // This runs after the root store has been initialized and rehydrated. // 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) - - // @mst replace-next-line }, []) - }) + 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 ( - !rehydrated || // @mst remove-current-line + !rehydrated || !isNavigationStateRestored || !isI18nInitialized || (!areFontsLoaded && !fontLoadError) ) { - return null + return null; } const linking = { prefixes: [prefix], config, - } + }; // otherwise, we're ready to render the app return ( @@ -122,5 +107,5 @@ export function App() { - ) + ); } diff --git a/boilerplate/app/client/client.gen.ts b/boilerplate/app/client/client.gen.ts new file mode 100644 index 000000000..5b3ffb181 --- /dev/null +++ b/boilerplate/app/client/client.gen.ts @@ -0,0 +1,24 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ClientOptions } from './types.gen'; +import { + type Config, + type ClientOptions as DefaultClientOptions, + createClient, + createConfig, +} from '@hey-api/client-fetch'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = + ( + override?: Config, + ) => Config & T>; + +export const client = createClient(createConfig()); diff --git a/boilerplate/app/client/index.ts b/boilerplate/app/client/index.ts new file mode 100644 index 000000000..0b26d1450 --- /dev/null +++ b/boilerplate/app/client/index.ts @@ -0,0 +1,3 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; +export * from './sdk.gen'; diff --git a/boilerplate/app/client/sdk.gen.ts b/boilerplate/app/client/sdk.gen.ts new file mode 100644 index 000000000..94e97f706 --- /dev/null +++ b/boilerplate/app/client/sdk.gen.ts @@ -0,0 +1,96 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { + Options as ClientOptions, + TDataShape, + Client, +} from '@hey-api/client-fetch'; +import type { + ItemsControllerFindAllData, + ItemsControllerFindAllResponse, + ItemsControllerCreateData, + ItemsControllerCreateResponse, + ItemsControllerDeleteData, + ItemsControllerDeleteResponse, + HeartbeatControllerHeartbeatData, + HeartbeatControllerHeartbeatResponse, +} from './types.gen'; +import { client as _heyApiClient } from './client.gen'; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, +> = ClientOptions & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +export const itemsControllerFindAll = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).get< + ItemsControllerFindAllResponse, + unknown, + ThrowOnError + >({ + url: '/api/items', + ...options, + }); +}; + +export const itemsControllerCreate = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).post< + ItemsControllerCreateResponse, + unknown, + ThrowOnError + >({ + url: '/api/items', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); +}; + +export const itemsControllerDelete = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).delete< + ItemsControllerDeleteResponse, + unknown, + ThrowOnError + >({ + url: '/api/items/{id}', + ...options, + }); +}; + +/** + * Check if the service is up and running + */ +export const heartbeatControllerHeartbeat = < + ThrowOnError extends boolean = false, +>( + options?: Options, +) => { + return (options?.client ?? _heyApiClient).get< + HeartbeatControllerHeartbeatResponse, + unknown, + ThrowOnError + >({ + url: '/api/heartbeat', + ...options, + }); +}; diff --git a/boilerplate/app/client/types.gen.ts b/boilerplate/app/client/types.gen.ts new file mode 100644 index 000000000..3a2777648 --- /dev/null +++ b/boilerplate/app/client/types.gen.ts @@ -0,0 +1,156 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type Item = { + /** + * Unique identifier of the item which can be used in the URL + */ + id: string; + /** + * Unique identifier of the user who created the item + */ + userId: string; + /** + * First name of the item + */ + description: string; + /** + * Last name of the item + */ + title: string; + /** + * UNIX timestamp in seconds when the item was created + */ + createdAt: string; + /** + * UNIX timestamp in seconds when the item was last updated + */ + updatedAt: string; +}; + +export type ItemResponse = { + /** + * Previous page token string + */ + prev?: number; + /** + * Next page token string + */ + next?: number; + /** + * Array of items + */ + items: Array; +}; + +export type ItemCreationSchema = { + /** + * First name of the item + */ + description: string; + /** + * Last name of the item + */ + title: string; + /** + * Unique identifier of the user who created the item + */ + userId: string; +}; + +export type HeartbeatResponse = { + /** + * Service state + */ + state: string; +}; + +export type ItemsControllerFindAllData = { + body?: never; + path?: never; + query: { + /** + * Limit for pagination + */ + limit?: number; + /** + * Next page token string - oldest tweet of the page + */ + nextPageToken?: string; + /** + * Previous page token string - latest tweet of the page + */ + prevPageToken?: string; + /** + * Limit for pagination + */ + userId: string; + }; + url: '/api/items'; +}; + +export type ItemsControllerFindAllResponses = { + /** + * The records have been successfully found. + */ + 200: ItemResponse; +}; + +export type ItemsControllerFindAllResponse = + ItemsControllerFindAllResponses[keyof ItemsControllerFindAllResponses]; + +export type ItemsControllerCreateData = { + body: ItemCreationSchema; + path?: never; + query?: never; + url: '/api/items'; +}; + +export type ItemsControllerCreateResponses = { + /** + * The record has been successfully created. + */ + 201: Item; +}; + +export type ItemsControllerCreateResponse = + ItemsControllerCreateResponses[keyof ItemsControllerCreateResponses]; + +export type ItemsControllerDeleteData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/api/items/{id}'; +}; + +export type ItemsControllerDeleteResponses = { + /** + * The record has been successfully deleted. + */ + 200: Item; +}; + +export type ItemsControllerDeleteResponse = + ItemsControllerDeleteResponses[keyof ItemsControllerDeleteResponses]; + +export type HeartbeatControllerHeartbeatData = { + body?: never; + path?: never; + query?: never; + url: '/api/heartbeat'; +}; + +export type HeartbeatControllerHeartbeatResponses = { + /** + * The service is up and running + */ + 200: HeartbeatResponse; +}; + +export type HeartbeatControllerHeartbeatResponse = + HeartbeatControllerHeartbeatResponses[keyof HeartbeatControllerHeartbeatResponses]; + +export type ClientOptions = { + baseUrl: string; +}; diff --git a/boilerplate/app/components/Icon.tsx b/boilerplate/app/components/Icon.tsx index 003893da5..e016ae7d5 100644 --- a/boilerplate/app/components/Icon.tsx +++ b/boilerplate/app/components/Icon.tsx @@ -1,3 +1,4 @@ +import { ComponentType } from 'react'; import { Image, ImageStyle, @@ -7,81 +8,43 @@ import { View, ViewProps, ViewStyle, -} from "react-native" -import { useAppTheme } from "@/utils/useAppTheme" +} from 'react-native'; +import { useAppTheme } from '@/utils/useAppTheme'; -export type IconTypes = keyof typeof iconRegistry +export type IconTypes = keyof typeof iconRegistry; -type BaseIconProps = { +interface IconProps extends TouchableOpacityProps { /** * The name of the icon */ - icon: IconTypes + icon: IconTypes; /** * An optional tint color for the icon */ - color?: string + color?: string; /** * An optional size for the icon. If not provided, the icon will be sized to the icon's resolution. */ - size?: number + size?: number; /** * Style overrides for the icon image */ - style?: StyleProp + style?: StyleProp; /** * Style overrides for the icon container */ - containerStyle?: StyleProp -} - -type PressableIconProps = Omit & BaseIconProps -type IconProps = Omit & BaseIconProps - -/** - * A component to render a registered icon. - * It is wrapped in a - * @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Icon/} - * @param {PressableIconProps} props - The props for the `PressableIcon` component. - * @returns {JSX.Element} The rendered `PressableIcon` component. - */ -export function PressableIcon(props: PressableIconProps) { - const { - icon, - color, - size, - style: $imageStyleOverride, - containerStyle: $containerStyleOverride, - ...pressableProps - } = props + containerStyle?: StyleProp; - const { theme } = useAppTheme() - - const $imageStyle: StyleProp = [ - $imageStyleBase, - { tintColor: color ?? theme.colors.text }, - size !== undefined && { width: size, height: size }, - $imageStyleOverride, - ] - - return ( - - - - ) + /** + * An optional function to be called when the icon is pressed + */ + onPress?: TouchableOpacityProps['onPress']; } -/** - * A component to render a registered icon. - * It is wrapped in a , use `PressableIcon` if you want to react to input - * @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Icon/} - * @param {IconProps} props - The props for the `Icon` component. - * @returns {JSX.Element} The rendered `Icon` component. - */ export function Icon(props: IconProps) { const { icon, @@ -89,50 +52,46 @@ export function Icon(props: IconProps) { size, style: $imageStyleOverride, containerStyle: $containerStyleOverride, - ...viewProps - } = props + ...WrapperProps + } = props; + + const isPressable = !!WrapperProps.onPress; + const Wrapper = ( + WrapperProps?.onPress ? TouchableOpacity : View + ) as ComponentType; - const { theme } = useAppTheme() + const { theme } = useAppTheme(); const $imageStyle: StyleProp = [ $imageStyleBase, { tintColor: color ?? theme.colors.text }, size !== undefined && { width: size, height: size }, $imageStyleOverride, - ] + ]; return ( - + - - ) + + ); } export const iconRegistry = { - back: require("../../assets/icons/back.png"), - bell: require("../../assets/icons/bell.png"), - caretLeft: require("../../assets/icons/caretLeft.png"), - caretRight: require("../../assets/icons/caretRight.png"), - check: require("../../assets/icons/check.png"), - clap: require("../../assets/icons/demo/clap.png"), // @demo remove-current-line - community: require("../../assets/icons/demo/community.png"), // @demo remove-current-line - components: require("../../assets/icons/demo/components.png"), // @demo remove-current-line - debug: require("../../assets/icons/demo/debug.png"), // @demo remove-current-line - github: require("../../assets/icons/demo/github.png"), // @demo remove-current-line - heart: require("../../assets/icons/demo/heart.png"), // @demo remove-current-line - hidden: require("../../assets/icons/hidden.png"), - ladybug: require("../../assets/icons/ladybug.png"), - lock: require("../../assets/icons/lock.png"), - menu: require("../../assets/icons/menu.png"), - more: require("../../assets/icons/more.png"), - pin: require("../../assets/icons/demo/pin.png"), // @demo remove-current-line - podcast: require("../../assets/icons/demo/podcast.png"), // @demo remove-current-line - settings: require("../../assets/icons/settings.png"), - slack: require("../../assets/icons/demo/slack.png"), // @demo remove-current-line - view: require("../../assets/icons/view.png"), - x: require("../../assets/icons/x.png"), -} + back: require('../../assets/icons/back.png'), + bell: require('../../assets/icons/bell.png'), + caretLeft: require('../../assets/icons/caretLeft.png'), + caretRight: require('../../assets/icons/caretRight.png'), + check: require('../../assets/icons/check.png'), + hidden: require('../../assets/icons/hidden.png'), + view: require('../../assets/icons/view.png'), + remove: require('../../assets/icons/remove_item.png'), + x: require('../../assets/icons/x.png'), +}; const $imageStyleBase: ImageStyle = { - resizeMode: "contain", -} + resizeMode: 'contain', +}; diff --git a/boilerplate/app/components/ListItem.tsx b/boilerplate/app/components/ListItem.tsx index 17803a20f..f467d1eb2 100644 --- a/boilerplate/app/components/ListItem.tsx +++ b/boilerplate/app/components/ListItem.tsx @@ -1,4 +1,4 @@ -import { forwardRef, ReactElement, ComponentType } from "react" +import { forwardRef, ReactElement } from 'react'; import { StyleProp, TextStyle, @@ -6,104 +6,98 @@ import { TouchableOpacityProps, View, ViewStyle, -} from "react-native" -import { $styles } from "../theme" -import { Icon, IconTypes } from "./Icon" -import { Text, TextProps } from "./Text" -import type { ThemedStyle } from "@/theme" -import { useAppTheme } from "@/utils/useAppTheme" +} from 'react-native'; +import { $styles } from '../theme'; +import { Icon, IconTypes } from './Icon'; +import { Text, TextProps } from './Text'; +import type { ThemedStyle } from '@/theme'; +import { useAppTheme } from '@/utils/useAppTheme'; export interface ListItemProps extends TouchableOpacityProps { /** * How tall the list item should be. * Default: 56 */ - height?: number + height?: number; /** * Whether to show the top separator. * Default: false */ - topSeparator?: boolean + topSeparator?: boolean; /** * Whether to show the bottom separator. * Default: false */ - bottomSeparator?: boolean + bottomSeparator?: boolean; /** * Text to display if not using `tx` or nested components. */ - text?: TextProps["text"] + text?: TextProps['text']; /** * Text which is looked up via i18n. */ - tx?: TextProps["tx"] + tx?: TextProps['tx']; /** * Children components. */ - children?: TextProps["children"] + children?: TextProps['children']; /** * Optional options to pass to i18n. Useful for interpolation * as well as explicitly setting locale or translation fallbacks. */ - txOptions?: TextProps["txOptions"] + txOptions?: TextProps['txOptions']; /** * Optional text style override. */ - textStyle?: StyleProp + textStyle?: StyleProp; /** * Pass any additional props directly to the Text component. */ - TextProps?: TextProps + TextProps?: TextProps; /** * Optional View container style override. */ - containerStyle?: StyleProp + containerStyle?: StyleProp; /** * Optional TouchableOpacity style override. */ - style?: StyleProp + style?: StyleProp; /** * Icon that should appear on the left. */ - leftIcon?: IconTypes + leftIcon?: IconTypes; /** * An optional tint color for the left icon */ - leftIconColor?: string + leftIconColor?: string; /** * Icon that should appear on the right. */ - rightIcon?: IconTypes + rightIcon?: IconTypes; /** * An optional tint color for the right icon */ - rightIconColor?: string + rightIconColor?: string; /** * Right action custom ReactElement. * Overrides `rightIcon`. */ - RightComponent?: ReactElement + RightComponent?: ReactElement; /** * Left action custom ReactElement. * Overrides `leftIcon`. */ - LeftComponent?: ReactElement + LeftComponent?: ReactElement; } interface ListItemActionProps { - icon?: IconTypes - iconColor?: string - Component?: ReactElement - size: number - side: "left" | "right" + icon?: IconTypes; + iconColor?: string; + Component?: ReactElement; + size: number; + side: 'left' | 'right'; } -/** - * A styled row component that can be used in FlatList, SectionList, or by itself. - * @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/ListItem/} - * @param {ListItemProps} props - The props for the `ListItem` component. - * @returns {JSX.Element} The rendered `ListItem` component. - */ export const ListItem = forwardRef(function ListItem( props: ListItemProps, ref, @@ -127,30 +121,27 @@ export const ListItem = forwardRef(function ListItem( textStyle: $textStyleOverride, containerStyle: $containerStyleOverride, ...TouchableOpacityProps - } = props - const { themed } = useAppTheme() + } = props; + const { themed } = useAppTheme(); - const isTouchable = - TouchableOpacityProps.onPress !== undefined || - TouchableOpacityProps.onPressIn !== undefined || - TouchableOpacityProps.onPressOut !== undefined || - TouchableOpacityProps.onLongPress !== undefined - - const $textStyles = [$textStyle, $textStyleOverride, TextProps?.style] + const $textStyles = [$textStyle, $textStyleOverride, TextProps?.style]; const $containerStyles = [ topSeparator && $separatorTop, bottomSeparator && $separatorBottom, $containerStyleOverride, - ] - - const $touchableStyles = [$styles.row, $touchableStyle, { minHeight: height }, style] + ]; - const Wrapper: ComponentType = isTouchable ? TouchableOpacity : View + const $touchableStyles = [ + $styles.row, + $touchableStyle, + { minHeight: height }, + style, + ]; return ( - + (function ListItem( Component={LeftComponent} /> - + {children} @@ -170,22 +167,22 @@ export const ListItem = forwardRef(function ListItem( iconColor={rightIconColor} Component={RightComponent} /> - + - ) -}) + ); +}); /** * @param {ListItemActionProps} props - The props for the `ListItemAction` component. * @returns {JSX.Element | null} The rendered `ListItemAction` component. */ function ListItemAction(props: ListItemActionProps) { - const { icon, Component, iconColor, size, side } = props - const { themed } = useAppTheme() + const { icon, Component, iconColor, size, side } = props; + const { themed } = useAppTheme(); - const $iconContainerStyles = [$iconContainer] + const $iconContainerStyles = [$iconContainer]; - if (Component) return Component + if (Component) return Component; if (icon !== undefined) { return ( @@ -195,47 +192,47 @@ function ListItemAction(props: ListItemActionProps) { color={iconColor} containerStyle={themed([ $iconContainerStyles, - side === "left" && $iconContainerLeft, - side === "right" && $iconContainerRight, + side === 'left' && $iconContainerLeft, + side === 'right' && $iconContainerRight, { height: size }, ])} /> - ) + ); } - return null + return null; } const $separatorTop: ThemedStyle = ({ colors }) => ({ borderTopWidth: 1, borderTopColor: colors.separator, -}) +}); const $separatorBottom: ThemedStyle = ({ colors }) => ({ borderBottomWidth: 1, borderBottomColor: colors.separator, -}) +}); const $textStyle: ThemedStyle = ({ spacing }) => ({ paddingVertical: spacing.xs, - alignSelf: "center", + alignSelf: 'center', flexGrow: 1, flexShrink: 1, -}) +}); const $touchableStyle: ViewStyle = { - alignItems: "flex-start", -} + alignItems: 'flex-start', +}; const $iconContainer: ViewStyle = { - justifyContent: "center", - alignItems: "center", + justifyContent: 'center', + alignItems: 'center', flexGrow: 0, -} +}; const $iconContainerLeft: ThemedStyle = ({ spacing }) => ({ marginEnd: spacing.md, -}) +}); const $iconContainerRight: ThemedStyle = ({ spacing }) => ({ marginStart: spacing.md, -}) +}); diff --git a/boilerplate/app/components/index.ts b/boilerplate/app/components/index.ts index 43506380f..07cc87deb 100644 --- a/boilerplate/app/components/index.ts +++ b/boilerplate/app/components/index.ts @@ -1,12 +1,7 @@ -export * from "./AutoImage" -export * from "./Button" -export * from "./Card" -export * from "./Header" -export * from "./Icon" -export * from "./ListItem" -export * from "./ListView" -export * from "./Screen" -export * from "./Text" -export * from "./TextField" -export * from "./Toggle" -export * from "./EmptyState" +export * from './Button'; +export * from './Header'; +export * from './Icon'; +export * from './ListItem'; +export * from './Screen'; +export * from './Text'; +export * from './TextField'; diff --git a/boilerplate/app/config/config.dev.ts b/boilerplate/app/config/config.dev.ts index 258c51fc1..1594b8aeb 100644 --- a/boilerplate/app/config/config.dev.ts +++ b/boilerplate/app/config/config.dev.ts @@ -6,5 +6,6 @@ * https://reactnative.dev/docs/security#storing-sensitive-info */ export default { - API_URL: "https://api.rss2json.com/v1/", -} + API_URL: 'https://b7fb-42-200-157-135.ngrok-free.app/', + USE_API_ITEMS: true, +}; diff --git a/boilerplate/app/config/config.prod.ts b/boilerplate/app/config/config.prod.ts index 852e99ff4..3ed58b48e 100644 --- a/boilerplate/app/config/config.prod.ts +++ b/boilerplate/app/config/config.prod.ts @@ -6,5 +6,6 @@ * https://reactnative.dev/docs/security#storing-sensitive-info */ export default { - API_URL: "https://api.rss2json.com/v1/", -} + API_URL: 'CHANGEME', + USE_API_ITEMS: true, +}; diff --git a/boilerplate/app/devtools/ReactotronClient.web.ts b/boilerplate/app/devtools/ReactotronClient.web.ts index e7fbed462..22db8d420 100644 --- a/boilerplate/app/devtools/ReactotronClient.web.ts +++ b/boilerplate/app/devtools/ReactotronClient.web.ts @@ -7,5 +7,5 @@ * If your project does not need web support, you can delete this file and * remove reactotron-react-js from your package.json dependencies. */ -import Reactotron from "reactotron-react-js" -export { Reactotron } +import Reactotron from 'reactotron-react-js'; +export { Reactotron }; diff --git a/boilerplate/app/i18n/en.ts b/boilerplate/app/i18n/en.ts index 105e6325d..5889284c1 100644 --- a/boilerplate/app/i18n/en.ts +++ b/boilerplate/app/i18n/en.ts @@ -1,130 +1,60 @@ -import demoEn from "./demo-en" // @demo remove-current-line - const en = { common: { - ok: "OK!", - cancel: "Cancel", - back: "Back", - logOut: "Log Out", // @demo remove-current-line - }, - welcomeScreen: { - postscript: - "psst — This probably isn't what your app looks like. (Unless your designer handed you these screens, and in that case, ship it!)", - readyForLaunch: "Your app, almost ready for launch!", - exciting: "(ohh, this is exciting!)", - letsGo: "Let's go!", // @demo remove-current-line + ok: 'OK!', + cancel: 'Cancel', + back: 'Back', + logOut: 'Log Out', + hello: 'Hello', + lang: 'En/Zh', + yes: 'Yes', + no: 'No', + }, + validationErrors: { + empty: "can't be blank", + onlyLetters: 'must contain only letters', + invalidName: 'must be at least 2 characters', + invalidEmail: 'must be a valid email address', + invalidEmailLength: 'must be at least 6 characters', + invalidPassword: 'must be at least 12 characters', + invalidPasswordLowercase: 'must contain at least one lowercase letter', + invalidPasswordUppercase: 'must contain at least one uppercase letter', + invalidPasswordNumber: 'must contain at least one number', + invalidPasswordSpecial: 'must contain at least one special character', }, errorScreen: { - title: "Something went wrong!", + title: 'Something went wrong!', friendlySubtitle: "This is the screen that your users will see in production when an error is thrown. You'll want to customize this message (located in `app/i18n/en.ts`) and probably the layout as well (`app/screens/ErrorScreen`). If you want to remove this entirely, check `app/app.tsx` for the component.", - reset: "RESET APP", - traceTitle: "Error from %{name} stack", // @demo remove-current-line - }, - emptyStateComponent: { - generic: { - heading: "So empty... so sad", - content: "No data found yet. Try clicking the button to refresh or reload the app.", - button: "Let's try this again", - }, + reset: 'RESET APP', + traceTitle: 'Error from %{name} stack', }, - // @demo remove-block-start errors: { - invalidEmail: "Invalid email address.", + invalidEmail: 'Invalid email address.', + }, + homeScreen: { + addItem: 'Add Item', + itemName: 'Item Name', + itemDescription: 'Item Description', + itemNamePlaceholder: 'Enter item name', + itemDescriptionPlaceholder: 'Enter item description', + removeItem: 'Are you sure you want to remove this item?', + createdOn: 'Created on', }, loginScreen: { - logIn: "Log In", + logIn: 'Log In', enterDetails: - "Enter your details below to unlock top secret info. You'll never guess what we've got waiting. Or maybe you will; it's not rocket science here.", - emailFieldLabel: "Email", - passwordFieldLabel: "Password", - emailFieldPlaceholder: "Enter your email address", - passwordFieldPlaceholder: "Super secret password here", - tapToLogIn: "Tap to log in!", - hint: "Hint: you can use any email address and your favorite password :)", - }, - demoNavigator: { - componentsTab: "Components", - debugTab: "Debug", - communityTab: "Community", - podcastListTab: "Podcast", - }, - demoCommunityScreen: { - title: "Connect with the community", - tagLine: - "Plug in to Infinite Red's community of React Native engineers and level up your app development with us!", - joinUsOnSlackTitle: "Join us on Slack", - joinUsOnSlack: - "Wish there was a place to connect with React Native engineers around the world? Join the conversation in the Infinite Red Community Slack! Our growing community is a safe space to ask questions, learn from others, and grow your network.", - joinSlackLink: "Join the Slack Community", - makeIgniteEvenBetterTitle: "Make Ignite even better", - makeIgniteEvenBetter: - "Have an idea to make Ignite even better? We're happy to hear that! We're always looking for others who want to help us build the best React Native tooling out there. Join us over on GitHub to join us in building the future of Ignite.", - contributeToIgniteLink: "Contribute to Ignite", - theLatestInReactNativeTitle: "The latest in React Native", - theLatestInReactNative: "We're here to keep you current on all React Native has to offer.", - reactNativeRadioLink: "React Native Radio", - reactNativeNewsletterLink: "React Native Newsletter", - reactNativeLiveLink: "React Native Live", - chainReactConferenceLink: "Chain React Conference", - hireUsTitle: "Hire Infinite Red for your next project", - hireUs: - "Whether it's running a full project or getting teams up to speed with our hands-on training, Infinite Red can help with just about any React Native project.", - hireUsLink: "Send us a message", - }, - demoShowroomScreen: { - jumpStart: "Components to jump start your project!", - lorem2Sentences: - "Nulla cupidatat deserunt amet quis aliquip nostrud do adipisicing. Adipisicing excepteur elit laborum Lorem adipisicing do duis.", - demoHeaderTxExample: "Yay", - demoViaTxProp: "Via `tx` Prop", - demoViaSpecifiedTxProp: "Via `{{prop}}Tx` Prop", - }, - demoDebugScreen: { - howTo: "HOW TO", - title: "Debug", - tagLine: - "Congratulations, you've got a very advanced React Native app template here. Take advantage of this boilerplate!", - reactotron: "Send to Reactotron", - reportBugs: "Report Bugs", - demoList: "Demo List", - demoPodcastList: "Demo Podcast List", - androidReactotronHint: - "If this doesn't work, ensure the Reactotron desktop app is running, run adb reverse tcp:9090 tcp:9090 from your terminal, and reload the app.", - iosReactotronHint: - "If this doesn't work, ensure the Reactotron desktop app is running and reload app.", - macosReactotronHint: - "If this doesn't work, ensure the Reactotron desktop app is running and reload app.", - webReactotronHint: - "If this doesn't work, ensure the Reactotron desktop app is running and reload app.", - windowsReactotronHint: - "If this doesn't work, ensure the Reactotron desktop app is running and reload app.", - }, - demoPodcastListScreen: { - title: "React Native Radio episodes", - onlyFavorites: "Only Show Favorites", - favoriteButton: "Favorite", - unfavoriteButton: "Unfavorite", - accessibility: { - cardHint: - "Double tap to listen to the episode. Double tap and hold to {{action}} this episode.", - switch: "Switch on to only show favorites", - favoriteAction: "Toggle Favorite", - favoriteIcon: "Episode not favorited", - unfavoriteIcon: "Episode favorited", - publishLabel: "Published {{date}}", - durationLabel: "Duration: {{hours}} hours {{minutes}} minutes {{seconds}} seconds", - }, - noFavoritesEmptyState: { - heading: "This looks a bit empty", - content: - "No favorites have been added yet. Tap the heart on an episode to add it to your favorites!", - }, - }, - // @demo remove-block-start - ...demoEn, - // @demo remove-block-end -} + 'Enter your details below to access Spacebox React Native app.', + emailFieldLabel: 'Email', + passwordFieldLabel: 'Password', + firstNameFieldLabel: 'First Name', + lastNameFieldLabel: 'Last Name', + emailFieldPlaceholder: 'Enter your email address', + passwordFieldPlaceholder: 'Super secret password here', + firstNameFieldPlaceholder: 'Enter your first name', + lastNameFieldPlaceholder: 'Enter your last name', + tapToLogIn: 'Tap to log in!', + }, +}; -export default en -export type Translations = typeof en +export default en; +export type Translations = typeof en; diff --git a/boilerplate/app/i18n/translate.ts b/boilerplate/app/i18n/translate.ts index 30951db43..b24e09ec2 100644 --- a/boilerplate/app/i18n/translate.ts +++ b/boilerplate/app/i18n/translate.ts @@ -1,6 +1,6 @@ -import i18n from "i18next" -import type { TOptions } from "i18next" -import { TxKeyPath } from "./i18n" +import i18n from 'i18next'; +import type { TOptions } from 'i18next'; +import { TxKeyPath } from './i18n'; /** * Translates text. @@ -20,13 +20,13 @@ import { TxKeyPath } from "./i18n" * ```ts * import { translate } from "./i18n" * - * translate("hello", { name: "world" }) + * translate("common:ok", { name: "world" }) * // => "Hello world!" * ``` */ export function translate(key: TxKeyPath, options?: TOptions): string { if (i18n.isInitialized) { - return i18n.t(key, options) + return i18n.t(key, options); } - return key + return key; } diff --git a/boilerplate/app/i18n/zh.ts b/boilerplate/app/i18n/zh.ts new file mode 100644 index 000000000..a9672662f --- /dev/null +++ b/boilerplate/app/i18n/zh.ts @@ -0,0 +1,59 @@ +const zh_TW = { + common: { + ok: '好的!', + cancel: '取消', + back: '返回', + logOut: '登出', + hello: '你好', + lang: '中/英', + yes: '是', + no: '否', + }, + validationErrors: { + empty: '不能為空', + onlyLetters: '只能包含字母', + invalidName: '必須至少包含 2 個字符', + invalidEmail: '必須是有效的電子郵件地址', + invalidEmailLength: '必須至少包含 6 個字符', + invalidPassword: '必須至少包含 12 個字符', + invalidPasswordLowercase: '必須至少包含一個小寫字母', + invalidPasswordUppercase: '必須至少包含一個大寫字母', + invalidPasswordNumber: '必須至少包含一個數字', + invalidPasswordSpecial: '必須至少包含一個特殊字符', + }, + errorScreen: { + title: '出了些問題!', + friendlySubtitle: + '這是用戶在生產環境中看到的錯誤屏幕。您需要自定義此消息(位於 `app/i18n/zh_TW.ts`),可能還需要自定義佈局(`app/screens/ErrorScreen`)。如果您想完全移除此功能,請檢查 `app/app.tsx` 中的 組件。', + reset: '重置應用', + traceTitle: '%{name} 堆棧錯誤', + }, + errors: { + invalidEmail: '電子郵件地址無效。', + }, + homeScreen: { + addItem: '添加項目', + itemName: '項目名稱', + itemDescription: '項目描述', + itemNamePlaceholder: '輸入項目名稱', + itemDescriptionPlaceholder: '輸入項目描述', + removeItem: '您確定要刪除此項目嗎?', + createdOn: '創建於', + }, + loginScreen: { + logIn: '登入', + enterDetails: + '在下方輸入您的詳細信息以訪問 Spacebox 的 React Native 應用。', + emailFieldLabel: '電子郵件', + passwordFieldLabel: '密碼', + firstNameFieldLabel: '名字', + lastNameFieldLabel: '姓氏', + emailFieldPlaceholder: '輸入您的電子郵件地址', + passwordFieldPlaceholder: '在這裡輸入超級秘密的密碼', + firstNameFieldPlaceholder: '輸入您的名字', + lastNameFieldPlaceholder: '輸入您的姓氏', + tapToLogIn: '點擊登入!', + }, +}; + +export default zh_TW; diff --git a/boilerplate/app/models/AuthenticationStore.ts b/boilerplate/app/models/AuthenticationStore.ts index c3ad85e94..e5faeaf7d 100644 --- a/boilerplate/app/models/AuthenticationStore.ts +++ b/boilerplate/app/models/AuthenticationStore.ts @@ -1,37 +1,94 @@ -import { Instance, SnapshotOut, types } from "mobx-state-tree" +import { translate } from '@/i18n'; +import { Instance, SnapshotOut, types } from 'mobx-state-tree'; + +function validateName(name: string) { + if (name.length === 0) return translate('validationErrors:empty'); + if (name.length < 2) return translate('validationErrors:invalidName'); + if (!/^[a-zA-Z]+$/.test(name)) { + return translate('validationErrors:onlyLetters'); + } + return ''; +} export const AuthenticationStoreModel = types - .model("AuthenticationStore") + .model('AuthenticationStore') .props({ authToken: types.maybe(types.string), - authEmail: "", + userId: types.maybe(types.string), + authFirstName: '', + authLastName: '', + authEmail: '', + authPassword: '', }) .views((store) => ({ get isAuthenticated() { - return !!store.authToken + return !!store.authToken; + }, + get validationFirstNameError() { + return validateName(store.authFirstName); + }, + get validationLastNameError() { + return validateName(store.authLastName); }, - get validationError() { - if (store.authEmail.length === 0) return "can't be blank" - if (store.authEmail.length < 6) return "must be at least 6 characters" + + get validationPasswordError() { + if (store.authPassword?.length === 0) { + return translate('validationErrors:empty'); + } + if (store.authPassword?.length < 12) + return translate('validationErrors:invalidPassword'); + if (!/[a-z]/.test(store.authPassword)) + return translate('validationErrors:invalidPasswordLowercase'); + if (!/[A-Z]/.test(store.authPassword)) + return translate('validationErrors:invalidPasswordUppercase'); + if (!/[0-9]/.test(store.authPassword)) + return translate('validationErrors:invalidPasswordNumber'); + if (!/[^a-zA-Z0-9]/.test(store.authPassword)) + return translate('validationErrors:invalidPasswordSpecial'); + return ''; + }, + get validationEmailError() { + if (store.authEmail.length === 0) { + return translate('validationErrors:empty'); + } + if (store.authEmail.length < 6) { + return translate('validationErrors:invalidEmailLength'); + } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(store.authEmail)) - return "must be a valid email address" - return "" + return translate('validationErrors:invalidEmail'); + return ''; }, })) .actions((store) => ({ setAuthToken(value?: string) { - store.authToken = value + store.authToken = value; }, setAuthEmail(value: string) { - store.authEmail = value.replace(/ /g, "") + store.authEmail = value.replace(/ /g, ''); + }, + setUserId(value: string) { + store.userId = value; + }, + setAuthFirstName(value: string) { + store.authFirstName = value; + }, + setAuthLastName(value: string) { + store.authLastName = value; + }, + setAuthPassword(value: string) { + // This is a placeholder for setting the password + store.authPassword = value; }, logout() { - store.authToken = undefined - store.authEmail = "" + store.authToken = undefined; + store.authEmail = ''; + store.authFirstName = ''; + store.authLastName = ''; + store.authPassword = ''; }, - })) - -export interface AuthenticationStore extends Instance {} -export interface AuthenticationStoreSnapshot extends SnapshotOut {} + })); -// @demo remove-file +// export interface AuthenticationStore +// extends Instance {} +// export interface AuthenticationStoreSnapshot +// extends SnapshotOut {} diff --git a/boilerplate/app/models/Item.ts b/boilerplate/app/models/Item.ts new file mode 100644 index 000000000..37418a976 --- /dev/null +++ b/boilerplate/app/models/Item.ts @@ -0,0 +1,13 @@ +import { Instance, SnapshotIn, SnapshotOut, types } from 'mobx-state-tree'; + +export const ItemModel = types.model('Item').props({ + id: '', + title: '', + description: '', + createdAt: types.optional(types.string, () => new Date().toISOString()), + updatedAt: types.optional(types.string, () => new Date().toISOString()), +}); + +export interface Item extends Instance {} +export interface ItemSnapshotOut extends SnapshotOut {} +export interface ItemSnapshotIn extends SnapshotIn {} diff --git a/boilerplate/app/models/StorageStore.ts b/boilerplate/app/models/StorageStore.ts new file mode 100644 index 000000000..1f5f55d8d --- /dev/null +++ b/boilerplate/app/models/StorageStore.ts @@ -0,0 +1,92 @@ +import { Instance, SnapshotOut, types } from 'mobx-state-tree'; +import { Item, ItemModel } from './Item'; +import { withSetPropAction } from './helpers/withSetPropAction'; +import { + itemsControllerCreate, + itemsControllerDelete, + itemsControllerFindAll, +} from '../client'; +import { client } from '../client/client.gen'; +import Config from '@/config'; + +client.setConfig({ + baseUrl: Config.API_URL, +}); + +export const StorageStoreModel = types + .model('StorageStore') + .props({ + items: types.optional(types.array(ItemModel), []), + }) + .actions(withSetPropAction) + .actions((store) => ({ + async fetchItems(userId: string) { + if (Config.USE_API_ITEMS === false) { + return; + } + + const response = await itemsControllerFindAll({ + query: { + userId, + limit: 100, + }, + }); + if (response.data?.items) { + this.setItems(response.data.items); + } else { + console.error(`Error fetching items: ${JSON.stringify(response.data)}`); + } + }, + setItems(items: Item[]) { + try { + store.setProp('items', items); + } catch (error) { + console.error('Error setting items:', error); + } + }, + async addItemAsync( + userId: string, + item: { title: string; description: string }, + ) { + if (Config.USE_API_ITEMS === true) { + await itemsControllerCreate({ + body: { + userId, + title: item.title, + description: item.description, + }, + }); + this.fetchItems(userId); + } else { + const newId = Math.random().toString(36).substring(2, 15); + const createdItem = ItemModel.create({ + id: newId, + title: item.title, + description: item.description, + }); + this.addItem(createdItem); + } + }, + addItem(item: Item) { + store.items.unshift(item); + }, + async removeItemAsync(userId: string, item: Item) { + if (Config.USE_API_ITEMS === true) { + await itemsControllerDelete({ + path: { + id: item.id, + }, + }); + this.fetchItems(userId); + } else { + this.removeItem(item); + } + }, + removeItem(item: Item) { + store.items.remove(item); + }, + })); + +export interface StorageStore extends Instance {} +export interface StorageStoreSnapshot + extends SnapshotOut {} diff --git a/boilerplate/app/navigators/AppNavigator.tsx b/boilerplate/app/navigators/AppNavigator.tsx index e7e8790a9..16b5e74f6 100644 --- a/boilerplate/app/navigators/AppNavigator.tsx +++ b/boilerplate/app/navigators/AppNavigator.tsx @@ -4,19 +4,18 @@ * Generally speaking, it will contain an auth flow (registration, login, forgot password) * and a "main" flow which the user will use once logged in. */ +import { NavigationContainer } from '@react-navigation/native'; import { - NavigationContainer, - NavigatorScreenParams, // @demo remove-current-line -} from "@react-navigation/native" -import { createNativeStackNavigator, NativeStackScreenProps } from "@react-navigation/native-stack" -import { observer } from "mobx-react-lite" // @mst remove-current-line -import * as Screens from "@/screens" -import Config from "../config" -import { useStores } from "../models" // @demo remove-current-line -import { DemoNavigator, DemoTabParamList } from "./DemoNavigator" // @demo remove-current-line -import { navigationRef, useBackButtonHandler } from "./navigationUtilities" -import { useAppTheme, useThemeProvider } from "@/utils/useAppTheme" -import { ComponentProps } from "react" + createNativeStackNavigator, + NativeStackScreenProps, +} from '@react-navigation/native-stack'; +import { observer } from 'mobx-react-lite'; +import * as Screens from '@/screens'; +import Config from '../config'; +import { useStores } from '../models'; +import { navigationRef, useBackButtonHandler } from './navigationUtilities'; +import { useAppTheme, useThemeProvider } from '@/utils/useAppTheme'; +import { ComponentProps } from 'react'; /** * This type allows TypeScript to know what routes are defined in this navigator @@ -32,37 +31,30 @@ import { ComponentProps } from "react" * https://reactnavigation.org/docs/typescript/#organizing-types */ export type AppStackParamList = { - Welcome: undefined - Login: undefined // @demo remove-current-line - Demo: NavigatorScreenParams // @demo remove-current-line - // 🔥 Your screens go here - // IGNITE_GENERATOR_ANCHOR_APP_STACK_PARAM_LIST -} + Home: undefined; + Login: undefined; +}; /** * This is a list of all the route names that will exit the app if the back button * is pressed while in that screen. Only affects Android. */ -const exitRoutes = Config.exitRoutes +const exitRoutes = Config.exitRoutes; -export type AppStackScreenProps = NativeStackScreenProps< - AppStackParamList, - T -> +export type AppStackScreenProps = + NativeStackScreenProps; // Documentation: https://reactnavigation.org/docs/stack-navigator/ -const Stack = createNativeStackNavigator() +const Stack = createNativeStackNavigator(); -// @mst replace-next-line const AppStack = () => { const AppStack = observer(function AppStack() { - // @demo remove-block-start const { authenticationStore: { isAuthenticated }, - } = useStores() - // @demo remove-block-end + } = useStores(); + const { theme: { colors }, - } = useAppTheme() + } = useAppTheme(); return ( - {/* @demo remove-block-start */} {isAuthenticated ? ( <> - {/* @demo remove-block-end */} - - {/* @demo remove-block-start */} - + ) : ( <> )} - {/* @demo remove-block-end */} - {/** 🔥 Your screens go here */} - {/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */} - ) - // @mst replace-next-line } -}) + ); +}); export interface NavigationProps - extends Partial>> {} + extends Partial< + ComponentProps> + > {} -// @mst replace-next-line export const AppNavigator = (props: NavigationProps) => { -export const AppNavigator = observer(function AppNavigator(props: NavigationProps) { - const { themeScheme, navigationTheme, setThemeContextOverride, ThemeProvider } = - useThemeProvider() +export const AppNavigator = observer((props: NavigationProps) => { + const { + themeScheme, + navigationTheme, + setThemeContextOverride, + ThemeProvider, + } = useThemeProvider(); - useBackButtonHandler((routeName) => exitRoutes.includes(routeName)) + useBackButtonHandler((routeName) => exitRoutes.includes(routeName)); return ( - + - ) - // @mst replace-next-line } -}) + ); +}); diff --git a/boilerplate/app/screens/HomeScreen/form.tsx b/boilerplate/app/screens/HomeScreen/form.tsx new file mode 100644 index 000000000..55153752e --- /dev/null +++ b/boilerplate/app/screens/HomeScreen/form.tsx @@ -0,0 +1,146 @@ +import { FC } from 'react'; +import { Text, Button, TextField } from '@/components'; +import { type ThemedStyle } from '@/theme'; +import { Item } from '@/models/Item'; +import { translate } from '@/i18n'; +import { View } from 'react-native'; + +import * as styles from './styles'; + +export const FormAddItem: FC<{ + addItem: ( + userId: string, + item: { title: string; description: string }, + ) => void; + themed: (styleOrStyleFn: ThemedStyle) => ViewStyle; + userId: string; + itemName: string; + itemDescription: string; + setItemName: (name: string) => void; + setItemDescription: (description: string) => void; + isFormValid: boolean; + setIsFormValid: (isValid: boolean) => void; + setDisplayForm: (display: boolean) => void; +}> = ({ + addItem, + themed, + userId, + itemName, + itemDescription, + setItemName, + setItemDescription, + isFormValid, + setIsFormValid, + setDisplayForm, +}) => { + return ( + + + { + setItemName(text); + setIsFormValid(text.length > 1 && itemDescription.length > 1); + }} + /> + + { + setItemDescription(text); + setIsFormValid(itemName.length > 1 && text.length > 1); + }} + /> +