diff --git a/README.md b/README.md index 294b392bc..250e30722 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,6 @@ Here are a few videos / talks that introduce Ignite and show off some of its fea - -
- - Jamon's Code Quest on MobX-State-Tree
-
Intro to MobX-State-Tree
-
-
- @@ -70,8 +62,6 @@ Nothing makes it into Ignite unless it's been proven on projects that Infinite R | React | UI Framework | v19 | The most popular UI framework in the world | | TypeScript | Language | v5 | Static typechecking | | React Navigation | Navigation | v7 | Performant and consistent navigation framework | -| MobX-State-Tree | State Management | v7 | Observable state tree | -| MobX-React-Lite | React Integration | v4 | Re-render React performantly | | Expo | SDK | v53 | Allows (optional) Expo modules | | Expo Font | Custom Fonts | v13 | Import custom fonts | | Expo Localization | Internationalization | v16 | i18n support (including RTL!) | diff --git a/boilerplate/app/app.tsx b/boilerplate/app/app.tsx index 23b21f872..3a9d0bee2 100644 --- a/boilerplate/app/app.tsx +++ b/boilerplate/app/app.tsx @@ -21,10 +21,9 @@ import { useEffect, useState } from "react" import { initialWindowMetrics, SafeAreaProvider } from "react-native-safe-area-context" import { useFonts } from "expo-font" import * as Linking from "expo-linking" -import * as SplashScreen from "expo-splash-screen" import { KeyboardProvider } from "react-native-keyboard-controller" -import { useInitialRootStore } from "./models" // @mst remove-current-line +import { AuthProvider } from "./context/AuthContext" import { AppNavigator, useNavigationPersistence } from "./navigators" import * as storage from "./utils/storage" import { customFontsToLoad } from "./theme" @@ -75,30 +74,13 @@ export function App() { .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 }, []) - }) - // 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 - !isNavigationStateRestored || - !isI18nInitialized || - (!areFontsLoaded && !fontLoadError) - ) { + if (!isNavigationStateRestored || !isI18nInitialized || (!areFontsLoaded && !fontLoadError)) { return null } @@ -111,11 +93,13 @@ export function App() { return ( - + + + ) diff --git a/boilerplate/app/context/AuthContext.tsx b/boilerplate/app/context/AuthContext.tsx new file mode 100644 index 000000000..a302d4e43 --- /dev/null +++ b/boilerplate/app/context/AuthContext.tsx @@ -0,0 +1,53 @@ +import { createContext, FC, PropsWithChildren, useCallback, useContext, useMemo } from "react" +import { useMMKVString } from "react-native-mmkv" + +export type AuthContextType = { + isAuthenticated: boolean + authToken?: string + authEmail?: string + setAuthToken: (token?: string) => void + setAuthEmail: (email: string) => void + logout: () => void + validationError: string +} + +export const AuthContext = createContext(null) + +export interface AuthProviderProps {} + +export const AuthProvider: FC> = ({ children }) => { + const [authToken, setAuthToken] = useMMKVString("AuthProvider.authToken") + const [authEmail, setAuthEmail] = useMMKVString("AuthProvider.authEmail") + + const logout = useCallback(() => { + setAuthToken(undefined) + setAuthEmail("") + }, [setAuthEmail, setAuthToken]) + + const validationError = useMemo(() => { + if (!authEmail || authEmail.length === 0) return "can't be blank" + if (authEmail.length < 6) return "must be at least 6 characters" + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authEmail)) return "must be a valid email address" + return "" + }, [authEmail]) + + const value = { + isAuthenticated: !!authToken, + authToken, + authEmail, + setAuthToken, + setAuthEmail, + logout, + validationError, + } + + return {children} +} + +export const useAuth = () => { + const context = useContext(AuthContext) + if (!context) throw new Error("useAuth must be used within an AuthProvider") + return context +} + +// @demo remove-file diff --git a/boilerplate/app/context/EpisodeContext.tsx b/boilerplate/app/context/EpisodeContext.tsx new file mode 100644 index 000000000..71f1af889 --- /dev/null +++ b/boilerplate/app/context/EpisodeContext.tsx @@ -0,0 +1,154 @@ +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from "react" + +import { api } from "@/services/api" +import { translate } from "@/i18n/translate" +import { formatDate } from "@/utils/formatDate" + +export interface Episode { + guid: string + title: string + pubDate: string + link: string + author: string + thumbnail: string + description: string + content: string + enclosure: { + link: string + type: string + length: number + duration: number + rating: { scheme: string; value: string } + } + categories: string[] +} + +export type EpisodeContextType = { + totalEpisodes: number + totalFavorites: number + episodesForList: Episode[] + fetchEpisodes: () => Promise + favoritesOnly: boolean + toggleFavoritesOnly: () => void + hasFavorite: (episode: Episode) => boolean + toggleFavorite: (episode: Episode) => void +} + +export const EpisodeContext = createContext(null) + +export interface EpisodeProviderProps {} + +export const EpisodeProvider: FC> = ({ children }) => { + const [episodes, setEpisodes] = useState([]) + const [favorites, setFavorites] = useState([]) + const [favoritesOnly, setFavoritesOnly] = useState(false) + + const fetchEpisodes = useCallback(async () => { + const response = await api.getEpisodes() + if (response.kind === "ok") { + setEpisodes(response.episodes) + } else { + console.error(`Error fetching episodes: ${JSON.stringify(response)}`) + } + }, []) + + const toggleFavoritesOnly = useCallback(() => { + setFavoritesOnly((prev) => !prev) + }, []) + + const toggleFavorite = useCallback( + (episode: Episode) => { + if (favorites.some((fav) => fav === episode.guid)) { + setFavorites((prev) => prev.filter((fav) => fav !== episode.guid)) + } else { + setFavorites((prev) => [...prev, episode.guid]) + } + }, + [favorites], + ) + + const hasFavorite = useCallback( + (episode: Episode) => favorites.some((fav) => fav === episode.guid), + [favorites], + ) + + const episodesForList = useMemo(() => { + return favoritesOnly ? episodes.filter((episode) => favorites.includes(episode.guid)) : episodes + }, [episodes, favorites, favoritesOnly]) + + const value = { + totalEpisodes: episodes.length, + totalFavorites: favorites.length, + episodesForList, + fetchEpisodes, + favoritesOnly, + toggleFavoritesOnly, + hasFavorite, + toggleFavorite, + } + + return {children} +} + +export const useEpisodes = () => { + const context = useContext(EpisodeContext) + if (!context) throw new Error("useEpisodes must be used within an EpisodeProvider") + return context +} + +// A helper hook to extract and format episode details +export const useEpisode = (episode: Episode) => { + const { hasFavorite } = useEpisodes() + + const isFavorite = hasFavorite(episode) + + let datePublished + try { + const formatted = formatDate(episode.pubDate) + datePublished = { + textLabel: formatted, + accessibilityLabel: translate("demoPodcastListScreen:accessibility.publishLabel", { + date: formatted, + }), + } + } catch { + datePublished = { textLabel: "", accessibilityLabel: "" } + } + + const seconds = Number(episode.enclosure?.duration ?? 0) + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = Math.floor((seconds % 3600) % 60) + const duration = { + textLabel: `${h > 0 ? `${h}:` : ""}${m > 0 ? `${m}:` : ""}${s}`, + accessibilityLabel: translate("demoPodcastListScreen:accessibility.durationLabel", { + hours: h, + minutes: m, + seconds: s, + }), + } + + const trimmedTitle = episode.title?.trim() + const titleMatches = trimmedTitle?.match(/^(RNR.*\d)(?: - )(.*$)/) + const parsedTitleAndSubtitle = + titleMatches && titleMatches.length === 3 + ? { title: titleMatches[1], subtitle: titleMatches[2] } + : { title: trimmedTitle, subtitle: "" } + + return { + isFavorite, + datePublished, + duration, + parsedTitleAndSubtitle, + } +} + +// @demo remove-file diff --git a/boilerplate/app/devtools/ReactotronConfig.ts b/boilerplate/app/devtools/ReactotronConfig.ts index 3bf7ec9b1..ea59fda2e 100644 --- a/boilerplate/app/devtools/ReactotronConfig.ts +++ b/boilerplate/app/devtools/ReactotronConfig.ts @@ -5,14 +5,10 @@ */ import { Platform, NativeModules } from "react-native" import { ArgType } from "reactotron-core-client" -import { mst } from "reactotron-mst" // @mst remove-current-line import mmkvPlugin from "reactotron-react-native-mmkv" import { ReactotronReactNative } from "reactotron-react-native" -import { - storage, - clear, // @mst remove-current-line -} from "@/utils/storage" +import { storage } from "@/utils/storage" import { goBack, resetRoot, navigate } from "@/navigators/navigationUtilities" import { Reactotron } from "./ReactotronClient" @@ -25,15 +21,6 @@ const reactotron = Reactotron.configure({ }, }) -// @mst remove-block-start -reactotron.use( - mst({ - /* ignore some chatty `mobx-state-tree` actions */ - filter: (event) => /postProcessSnapshot|@APPLY_SNAPSHOT/.test(event.name) === false, - }), -) -// @mst remove-block-end - reactotron.use(mmkvPlugin({ storage })) if (Platform.OS !== "web") { @@ -65,18 +52,6 @@ reactotron.onCustomCommand({ }, }) -// @mst remove-block-start -reactotron.onCustomCommand({ - title: "Reset Root Store", - description: "Resets the MST store", - command: "resetStore", - handler: () => { - Reactotron.log("resetting store") - clear() - }, -}) -// @mst remove-block-end - reactotron.onCustomCommand({ title: "Reset Navigation State", description: "Resets the navigation state", diff --git a/boilerplate/app/models/AuthenticationStore.ts b/boilerplate/app/models/AuthenticationStore.ts deleted file mode 100644 index c3ad85e94..000000000 --- a/boilerplate/app/models/AuthenticationStore.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Instance, SnapshotOut, types } from "mobx-state-tree" - -export const AuthenticationStoreModel = types - .model("AuthenticationStore") - .props({ - authToken: types.maybe(types.string), - authEmail: "", - }) - .views((store) => ({ - get isAuthenticated() { - return !!store.authToken - }, - get validationError() { - if (store.authEmail.length === 0) return "can't be blank" - if (store.authEmail.length < 6) return "must be at least 6 characters" - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(store.authEmail)) - return "must be a valid email address" - return "" - }, - })) - .actions((store) => ({ - setAuthToken(value?: string) { - store.authToken = value - }, - setAuthEmail(value: string) { - store.authEmail = value.replace(/ /g, "") - }, - logout() { - store.authToken = undefined - store.authEmail = "" - }, - })) - -export interface AuthenticationStore extends Instance {} -export interface AuthenticationStoreSnapshot extends SnapshotOut {} - -// @demo remove-file diff --git a/boilerplate/app/models/Episode.test.ts b/boilerplate/app/models/Episode.test.ts deleted file mode 100644 index 635d72d04..000000000 --- a/boilerplate/app/models/Episode.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { EpisodeModel } from "./Episode" - -const data = { - guid: "f91f2ea0-378a-4a90-9a83-d438a0cc32f6", - title: "RNR 244 - Rewriting GasBuddy in React Native", - pubDate: "2022-01-20 21:05:36", - link: "https://www.reactnativeradio.com/", - author: - "rnradio@infinite.red (Max Metral, Mark Rickert, Jamon Holmgren, Robin Heinze, Mazen Chami)", - thumbnail: - "https://image.simplecastcdn.com/images/fd1212b1-7d08-4c5a-8506-00188a4c6528/acb9f5dc-7451-42af-8c97-2f0f29d122ae/3000x3000/rnr-episode-rnr244.jpg?aid=rss_feed", - description: "", - content: "", - enclosure: { - link: "https://www.simplecast.com/podcasts/rnr/rnr244", - type: "audio/mpeg", - length: 0, - duration: 2578, - rating: { - scheme: "urn:simplecast:classification", - value: "clean", - }, - }, -} -const episode = EpisodeModel.create(data) - -test("publish date format", () => { - expect(episode.datePublished.textLabel).toBe("Jan 20, 2022") - expect(episode.datePublished.accessibilityLabel).toBe( - "demoPodcastListScreen:accessibility.publishLabel", - ) -}) - -test("duration format", () => { - expect(episode.duration.textLabel).toBe("42:58") - expect(episode.duration.accessibilityLabel).toBe( - "demoPodcastListScreen:accessibility.durationLabel", - ) -}) - -// @demo remove-file diff --git a/boilerplate/app/models/Episode.ts b/boilerplate/app/models/Episode.ts deleted file mode 100644 index dc6ae29ec..000000000 --- a/boilerplate/app/models/Episode.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Instance, SnapshotIn, SnapshotOut, types } from "mobx-state-tree" - -import { formatDate } from "@/utils/formatDate" -import { translate } from "@/i18n" - -import { withSetPropAction } from "./helpers/withSetPropAction" - -interface Enclosure { - link: string - type: string - length: number - duration: number - rating: { scheme: string; value: string } -} - -/** - * This represents an episode of React Native Radio. - */ -export const EpisodeModel = types - .model("Episode") - .props({ - guid: types.identifier, - title: "", - pubDate: "", // Ex: 2022-08-12 21:05:36 - link: "", - author: "", - thumbnail: "", - description: "", - content: "", - enclosure: types.frozen(), - categories: types.array(types.string), - }) - .actions(withSetPropAction) - .views((episode) => ({ - get parsedTitleAndSubtitle() { - const defaultValue = { title: episode.title?.trim(), subtitle: "" } - - if (!defaultValue.title) return defaultValue - - const titleMatches = defaultValue.title.match(/^(RNR.*\d)(?: - )(.*$)/) - - if (!titleMatches || titleMatches.length !== 3) return defaultValue - - return { title: titleMatches[1], subtitle: titleMatches[2] } - }, - get datePublished() { - try { - const formatted = formatDate(episode.pubDate) - return { - textLabel: formatted, - accessibilityLabel: translate("demoPodcastListScreen:accessibility.publishLabel", { - date: formatted, - }), - } - } catch { - return { textLabel: "", accessibilityLabel: "" } - } - }, - get duration() { - const seconds = Number(episode.enclosure.duration) - const h = Math.floor(seconds / 3600) - const m = Math.floor((seconds % 3600) / 60) - const s = Math.floor((seconds % 3600) % 60) - - const hDisplay = h > 0 ? `${h}:` : "" - const mDisplay = m > 0 ? `${m}:` : "" - const sDisplay = s > 0 ? s : "" - return { - textLabel: hDisplay + mDisplay + sDisplay, - accessibilityLabel: translate("demoPodcastListScreen:accessibility.durationLabel", { - hours: h, - minutes: m, - seconds: s, - }), - } - }, - })) - -export interface Episode extends Instance {} -export interface EpisodeSnapshotOut extends SnapshotOut {} -export interface EpisodeSnapshotIn extends SnapshotIn {} - -// @demo remove-file diff --git a/boilerplate/app/models/EpisodeStore.ts b/boilerplate/app/models/EpisodeStore.ts deleted file mode 100644 index 439301e50..000000000 --- a/boilerplate/app/models/EpisodeStore.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Instance, SnapshotOut, types } from "mobx-state-tree" - -import { api } from "@/services/api" - -import { Episode, EpisodeModel } from "./Episode" -import { withSetPropAction } from "./helpers/withSetPropAction" - -export const EpisodeStoreModel = types - .model("EpisodeStore") - .props({ - episodes: types.array(EpisodeModel), - favorites: types.array(types.reference(EpisodeModel)), - favoritesOnly: false, - }) - .actions(withSetPropAction) - .actions((store) => ({ - async fetchEpisodes() { - const response = await api.getEpisodes() - if (response.kind === "ok") { - store.setProp("episodes", response.episodes) - } else { - console.error(`Error fetching episodes: ${JSON.stringify(response)}`) - } - }, - addFavorite(episode: Episode) { - store.favorites.push(episode) - }, - removeFavorite(episode: Episode) { - store.favorites.remove(episode) - }, - })) - .views((store) => ({ - get episodesForList() { - return store.favoritesOnly ? store.favorites : store.episodes - }, - - hasFavorite(episode: Episode) { - return store.favorites.includes(episode) - }, - })) - .actions((store) => ({ - toggleFavorite(episode: Episode) { - if (store.hasFavorite(episode)) { - store.removeFavorite(episode) - } else { - store.addFavorite(episode) - } - }, - })) - -export interface EpisodeStore extends Instance {} -export interface EpisodeStoreSnapshot extends SnapshotOut {} - -// @demo remove-file diff --git a/boilerplate/app/models/RootStore.ts b/boilerplate/app/models/RootStore.ts deleted file mode 100644 index 8051a21c3..000000000 --- a/boilerplate/app/models/RootStore.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Instance, SnapshotOut, types } from "mobx-state-tree" - -import { AuthenticationStoreModel } from "./AuthenticationStore" // @demo remove-current-line -import { EpisodeStoreModel } from "./EpisodeStore" // @demo remove-current-line - -/** - * A RootStore model. - */ -export const RootStoreModel = types.model("RootStore").props({ - authenticationStore: types.optional(AuthenticationStoreModel, {}), // @demo remove-current-line - episodeStore: types.optional(EpisodeStoreModel, {}), // @demo remove-current-line -}) - -/** - * The RootStore instance. - */ -export interface RootStore extends Instance {} -/** - * The data of a RootStore. - */ -export interface RootStoreSnapshot extends SnapshotOut {} - -// @mst remove-file diff --git a/boilerplate/app/models/helpers/getRootStore.ts b/boilerplate/app/models/helpers/getRootStore.ts deleted file mode 100644 index a4ed9c108..000000000 --- a/boilerplate/app/models/helpers/getRootStore.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getRoot, IStateTreeNode } from "mobx-state-tree" - -import { RootStore, RootStoreModel } from "../RootStore" - -/** - * Returns a RootStore object in strongly typed way - * for stores to access other stores. - * @param {IStateTreeNode} self - The store instance. - * @returns {RootStore} - The RootStore instance. - */ -export const getRootStore = (self: IStateTreeNode): RootStore => { - return getRoot(self) -} - -// @mst remove-file diff --git a/boilerplate/app/models/helpers/setupRootStore.ts b/boilerplate/app/models/helpers/setupRootStore.ts deleted file mode 100644 index b4f437a53..000000000 --- a/boilerplate/app/models/helpers/setupRootStore.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This file is where we do "rehydration" of your RootStore from AsyncStorage. - * This lets you persist your state between app launches. - * - * Navigation state persistence is handled in navigationUtilities.tsx. - * - * Note that Fast Refresh doesn't play well with this file, so if you edit this, - * do a full refresh of your app instead. - * - * @refresh reset - */ -import { applySnapshot, IDisposer, onSnapshot } from "mobx-state-tree" - -import * as storage from "@/utils/storage" - -import { RootStore, RootStoreSnapshot } from "../RootStore" - -/** - * The key we'll be saving our state as within async storage. - */ -const ROOT_STATE_STORAGE_KEY = "root-v1" - -/** - * Setup the root state. - */ -let _disposer: IDisposer | undefined -export async function setupRootStore(rootStore: RootStore) { - let restoredState: RootStoreSnapshot | undefined | null - - try { - // load the last known state from AsyncStorage - restoredState = ((await storage.load(ROOT_STATE_STORAGE_KEY)) ?? {}) as RootStoreSnapshot - applySnapshot(rootStore, restoredState) - } catch (e) { - // if there's any problems loading, then inform the dev what happened - if (__DEV__) { - if (e instanceof Error) console.error(e.message) - } - } - - // stop tracking state changes if we've already setup - if (_disposer) _disposer() - - // track changes & save to AsyncStorage - _disposer = onSnapshot(rootStore, (snapshot) => storage.save(ROOT_STATE_STORAGE_KEY, snapshot)) - - const unsubscribe = () => { - _disposer?.() - _disposer = undefined - } - - return { rootStore, restoredState, unsubscribe } -} - -// @mst remove-file diff --git a/boilerplate/app/models/helpers/useStores.ts b/boilerplate/app/models/helpers/useStores.ts deleted file mode 100644 index ace83c449..000000000 --- a/boilerplate/app/models/helpers/useStores.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createContext, useContext, useEffect, useState } from "react" - -import { RootStore, RootStoreModel } from "../RootStore" - -import { setupRootStore } from "./setupRootStore" - -/** - * Create the initial (empty) global RootStore instance here. - * - * Later, it will be rehydrated in app.tsx with the setupRootStore function. - * - * If your RootStore requires specific properties to be instantiated, - * you can do so here. - * - * If your RootStore has a _ton_ of sub-stores and properties (the tree is - * very large), you may want to use a different strategy than immediately - * instantiating it, although that should be rare. - */ -const _rootStore = RootStoreModel.create({}) - -/** - * The RootStoreContext provides a way to access - * the RootStore in any screen or component. - */ -const RootStoreContext = createContext(_rootStore) - -/** - * You can use this Provider to specify a *different* RootStore - * than the singleton version above if you need to. Generally speaking, - * this Provider & custom RootStore instances would only be used in - * testing scenarios. - */ -export const RootStoreProvider = RootStoreContext.Provider - -/** - * A hook that screens and other components can use to gain access to - * our stores: - * - * const rootStore = useStores() - * - * or: - * - * const { someStore, someOtherStore } = useStores() - */ -export const useStores = () => useContext(RootStoreContext) - -/** - * Used only in the app.tsx file, this hook sets up the RootStore - * and then rehydrates it. It connects everything with Reactotron - * and then lets the app know that everything is ready to go. - * @param {() => void | Promise} callback - an optional callback that's invoked once the store is ready - * @returns {object} - the RootStore and rehydrated state - */ -export const useInitialRootStore = (callback?: () => void | Promise) => { - const rootStore = useStores() - const [rehydrated, setRehydrated] = useState(false) - - // Kick off initial async loading actions, like loading fonts and rehydrating RootStore - useEffect(() => { - let _unsubscribe: () => void | undefined - ;(async () => { - // set up the RootStore (returns the state restored from AsyncStorage) - const { unsubscribe } = await setupRootStore(rootStore) - _unsubscribe = unsubscribe - - // reactotron integration with the MST root store (DEV only) - if (__DEV__) { - // @ts-ignore - console.tron.trackMstNode(rootStore) - } - - // let the app know we've finished rehydrating - setRehydrated(true) - - // invoke the callback, if provided - if (callback) callback() - })() - - return () => { - // cleanup - if (_unsubscribe !== undefined) _unsubscribe() - } - // only runs on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - return { rootStore, rehydrated } -} - -// @mst remove-file diff --git a/boilerplate/app/models/helpers/withSetPropAction.ts b/boilerplate/app/models/helpers/withSetPropAction.ts deleted file mode 100644 index 31723ea38..000000000 --- a/boilerplate/app/models/helpers/withSetPropAction.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IStateTreeNode, SnapshotIn } from "mobx-state-tree" - -/** - * If you include this in your model in an action() block just under your props, - * it'll allow you to set property values directly while retaining type safety - * and also is executed in an action. This is useful because often you find yourself - * making a lot of repetitive setter actions that only update one prop. - * - * E.g.: - * - * const UserModel = types.model("User") - * .props({ - * name: types.string, - * age: types.number - * }) - * .actions(withSetPropAction) - * - * const user = UserModel.create({ name: "Jamon", age: 40 }) - * - * user.setProp("name", "John") // no type error - * user.setProp("age", 30) // no type error - * user.setProp("age", "30") // type error -- must be number - */ -export const withSetPropAction = (mstInstance: T) => ({ - // generic setter for all properties - setProp, V extends SnapshotIn[K]>(field: K, newValue: V) { - // @ts-ignore - for some reason TS complains about this, but it still works fine - mstInstance[field] = newValue - }, -}) - -// @mst remove-file diff --git a/boilerplate/app/models/index.ts b/boilerplate/app/models/index.ts deleted file mode 100644 index fb0946df5..000000000 --- a/boilerplate/app/models/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./RootStore" -export * from "./helpers/getRootStore" -export * from "./helpers/useStores" -export * from "./helpers/setupRootStore" - -// @mst remove-file diff --git a/boilerplate/app/navigators/AppNavigator.tsx b/boilerplate/app/navigators/AppNavigator.tsx index e01509fe5..012f0e639 100644 --- a/boilerplate/app/navigators/AppNavigator.tsx +++ b/boilerplate/app/navigators/AppNavigator.tsx @@ -9,12 +9,11 @@ import { 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 { ComponentProps } from "react" -import * as Screens from "@/screens" +import { useAuth } from "@/context/AuthContext" import Config from "@/config" -import { useStores } from "@/models" // @demo remove-current-line +import * as Screens from "@/screens" import { useAppTheme, useThemeProvider } from "@/utils/useAppTheme" import { DemoNavigator, DemoTabParamList } from "./DemoNavigator" // @demo remove-current-line @@ -24,10 +23,6 @@ import { navigationRef, useBackButtonHandler } from "./navigationUtilities" * This type allows TypeScript to know what routes are defined in this navigator * as well as what properties (if any) they might take when navigating to them. * - * If no params are allowed, pass through `undefined`. Generally speaking, we - * recommend using your MobX-State-Tree store(s) to keep application state - * rather than passing state through navigation params. - * * For more information, see this documentation: * https://reactnavigation.org/docs/params/ * https://reactnavigation.org/docs/typescript#type-checking-the-navigator @@ -55,12 +50,9 @@ export type AppStackScreenProps = NativeStack // Documentation: https://reactnavigation.org/docs/stack-navigator/ const Stack = createNativeStackNavigator() -// @mst replace-next-line const AppStack = () => { -const AppStack = observer(function AppStack() { +const AppStack = () => { // @demo remove-block-start - const { - authenticationStore: { isAuthenticated }, - } = useStores() + const { isAuthenticated } = useAuth() // @demo remove-block-end const { theme: { colors }, @@ -95,14 +87,12 @@ const AppStack = observer(function AppStack() { {/* IGNITE_GENERATOR_ANCHOR_APP_STACK_SCREENS */} ) - // @mst replace-next-line } -}) +} export interface NavigationProps extends Partial>> {} -// @mst replace-next-line export const AppNavigator = (props: NavigationProps) => { -export const AppNavigator = observer(function AppNavigator(props: NavigationProps) { +export const AppNavigator = (props: NavigationProps) => { const { themeScheme, navigationTheme, setThemeContextOverride, ThemeProvider } = useThemeProvider() @@ -117,5 +107,4 @@ export const AppNavigator = observer(function AppNavigator(props: NavigationProp ) - // @mst replace-next-line } -}) +} diff --git a/boilerplate/app/navigators/DemoNavigator.tsx b/boilerplate/app/navigators/DemoNavigator.tsx index 6440d1b09..3e4a8d011 100644 --- a/boilerplate/app/navigators/DemoNavigator.tsx +++ b/boilerplate/app/navigators/DemoNavigator.tsx @@ -11,6 +11,7 @@ import type { ThemedStyle } from "@/theme" import { useAppTheme } from "@/utils/useAppTheme" import { AppStackParamList, AppStackScreenProps } from "./AppNavigator" +import { EpisodeProvider } from "@/context/EpisodeContext" export type DemoTabParamList = { DemoCommunity: undefined @@ -46,62 +47,72 @@ export function DemoNavigator() { } = useAppTheme() return ( - - ( - - ), + + + > + ( + + ), + }} + /> - ( - - ), - }} - /> + ( + + ), + }} + /> - ( - - ), - }} - /> + ( + + ), + }} + /> - ( - - ), - }} - /> - + ( + + ), + }} + /> + + ) } diff --git a/boilerplate/app/screens/DemoDebugScreen.tsx b/boilerplate/app/screens/DemoDebugScreen.tsx index d44e94ef0..2982d307e 100644 --- a/boilerplate/app/screens/DemoDebugScreen.tsx +++ b/boilerplate/app/screens/DemoDebugScreen.tsx @@ -15,8 +15,8 @@ import { DemoTabScreenProps } from "@/navigators/DemoNavigator" import type { ThemedStyle } from "@/theme" import { $styles } from "@/theme" import { isRTL } from "@/i18n" -import { useStores } from "@/models" import { useAppTheme } from "@/utils/useAppTheme" +import { useAuth } from "@/context/AuthContext" /** * @param {string} url - The URL to open in the browser. @@ -32,9 +32,7 @@ export const DemoDebugScreen: FC> = function Dem _props, ) { const { setThemeContextOverride, themeContext, themed } = useAppTheme() - const { - authenticationStore: { logout }, - } = useStores() + const { logout } = useAuth() // @ts-expect-error const usingFabric = global.nativeFabricUIManager != null diff --git a/boilerplate/app/screens/DemoPodcastListScreen.tsx b/boilerplate/app/screens/DemoPodcastListScreen.tsx index 215ca7391..7c5a64170 100644 --- a/boilerplate/app/screens/DemoPodcastListScreen.tsx +++ b/boilerplate/app/screens/DemoPodcastListScreen.tsx @@ -1,4 +1,3 @@ -import { observer } from "mobx-react-lite" import { ComponentType, FC, useCallback, useEffect, useMemo, useState } from "react" import { AccessibilityProps, @@ -33,8 +32,7 @@ import { Text, } from "@/components" import { isRTL, translate } from "@/i18n" -import { useStores } from "@/models" -import { Episode } from "@/models/Episode" +import { useEpisodes, Episode, useEpisode } from "@/context/EpisodeContext" import { DemoTabScreenProps } from "@/navigators/DemoNavigator" import type { ThemedStyle } from "@/theme" import { $styles } from "@/theme" @@ -50,108 +48,104 @@ const rnrImage3 = require("@assets/images/demo/rnr-image-3.png") const rnrImages = [rnrImage1, rnrImage2, rnrImage3] -export const DemoPodcastListScreen: FC> = observer( - function DemoPodcastListScreen(_props) { - const { episodeStore } = useStores() - const { themed } = useAppTheme() - - const [refreshing, setRefreshing] = useState(false) - const [isLoading, setIsLoading] = useState(false) - - // initially, kick off a background refresh without the refreshing UI - useEffect(() => { - ;(async function load() { - setIsLoading(true) - await episodeStore.fetchEpisodes() - setIsLoading(false) - })() - }, [episodeStore]) - - // simulate a longer refresh, if the refresh is too fast for UX - async function manualRefresh() { - setRefreshing(true) - await Promise.all([episodeStore.fetchEpisodes(), delay(750)]) - setRefreshing(false) - } +export const DemoPodcastListScreen: FC> = (_props) => { + const { themed } = useAppTheme() + const { + totalEpisodes, + totalFavorites, + + episodesForList, + fetchEpisodes, + favoritesOnly, + toggleFavoritesOnly, + toggleFavorite, + } = useEpisodes() + + const [refreshing, setRefreshing] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + // initially, kick off a background refresh without the refreshing UI + useEffect(() => { + ;(async function load() { + setIsLoading(true) + await fetchEpisodes() + setIsLoading(false) + })() + }, [fetchEpisodes]) + + // simulate a longer refresh, if the refresh is too fast for UX + async function manualRefresh() { + setRefreshing(true) + await Promise.allSettled([fetchEpisodes(), delay(750)]) + setRefreshing(false) + } - return ( - - - contentContainerStyle={themed([$styles.container, $listContentContainer])} - data={episodeStore.episodesForList.slice()} - extraData={episodeStore.favorites.length + episodeStore.episodes.length} - refreshing={refreshing} - estimatedItemSize={177} - onRefresh={manualRefresh} - ListEmptyComponent={ - isLoading ? ( - - ) : ( - - ) - } - ListHeaderComponent={ - - - {(episodeStore.favoritesOnly || episodeStore.episodesForList.length > 0) && ( - - - episodeStore.setProp("favoritesOnly", !episodeStore.favoritesOnly) - } - labelTx="demoPodcastListScreen:onlyFavorites" - labelPosition="left" - labelStyle={$labelStyle} - accessibilityLabel={translate("demoPodcastListScreen:accessibility.switch")} - /> - - )} - - } - renderItem={({ item }) => ( - episodeStore.toggleFavorite(item)} + return ( + + + contentContainerStyle={themed([$styles.container, $listContentContainer])} + data={episodesForList} + extraData={totalEpisodes + totalFavorites} + refreshing={refreshing} + estimatedItemSize={177} + onRefresh={manualRefresh} + ListEmptyComponent={ + isLoading ? ( + + ) : ( + - )} - /> - - ) - }, -) - -const EpisodeCard = observer(function EpisodeCard({ + ) + } + ListHeaderComponent={ + + + {(favoritesOnly || episodesForList.length > 0) && ( + + toggleFavoritesOnly()} + labelTx="demoPodcastListScreen:onlyFavorites" + labelPosition="left" + labelStyle={$labelStyle} + accessibilityLabel={translate("demoPodcastListScreen:accessibility.switch")} + /> + + )} + + } + renderItem={({ item }) => ( + toggleFavorite(item)} /> + )} + /> + + ) +} + +const EpisodeCard = ({ episode, - isFavorite, onPressFavorite, }: { episode: Episode onPressFavorite: () => void - isFavorite: boolean -}) { +}) => { const { theme: { colors }, themed, } = useAppTheme() + const { isFavorite, datePublished, duration, parsedTitleAndSubtitle } = useEpisode(episode) const liked = useSharedValue(isFavorite ? 1 : 0) const imageUri = useMemo(() => { @@ -267,20 +261,24 @@ const EpisodeCard = observer(function EpisodeCard({ - {episode.datePublished.textLabel} + {datePublished.textLabel} - {episode.duration.textLabel} + {duration.textLabel} } - content={`${episode.parsedTitleAndSubtitle.title} - ${episode.parsedTitleAndSubtitle.subtitle}`} + content={ + parsedTitleAndSubtitle.subtitle + ? `${parsedTitleAndSubtitle.title} - ${parsedTitleAndSubtitle.subtitle}` + : parsedTitleAndSubtitle.title + } {...accessibilityHintProps} RightComponent={} FooterComponent={ @@ -297,7 +295,7 @@ const EpisodeCard = observer(function EpisodeCard({ > ) -}) +} // #region Styles const $listContentContainer: ThemedStyle = ({ spacing }) => ({ diff --git a/boilerplate/app/screens/LoginScreen.tsx b/boilerplate/app/screens/LoginScreen.tsx index 5432859de..468510bcb 100644 --- a/boilerplate/app/screens/LoginScreen.tsx +++ b/boilerplate/app/screens/LoginScreen.tsx @@ -1,7 +1,6 @@ import { ComponentType, FC, useEffect, useMemo, useRef, useState } from "react" // eslint-disable-next-line no-restricted-imports import { TextInput, TextStyle, ViewStyle } from "react-native" -import { observer } from "mobx-react-lite" import { Button, @@ -11,23 +10,21 @@ import { TextField, TextFieldAccessoryProps, } from "@/components" -import { useStores } from "@/models" import { AppStackScreenProps } from "@/navigators" import type { ThemedStyle } from "@/theme" import { useAppTheme } from "@/utils/useAppTheme" +import { useAuth } from "@/context/AuthContext" interface LoginScreenProps extends AppStackScreenProps<"Login"> {} -export const LoginScreen: FC = observer(function LoginScreen(_props) { +export const LoginScreen: FC = (_props) => { const authPasswordInput = useRef(null) const [authPassword, setAuthPassword] = useState("") const [isAuthPasswordHidden, setIsAuthPasswordHidden] = useState(true) const [isSubmitted, setIsSubmitted] = useState(false) const [attemptsCount, setAttemptsCount] = useState(0) - const { - authenticationStore: { authEmail, setAuthEmail, setAuthToken, validationError }, - } = useStores() + const { authEmail, setAuthEmail, setAuthToken, validationError } = useAuth() const { themed, @@ -39,12 +36,6 @@ export const LoginScreen: FC = observer(function LoginScreen(_ // and pre-fill the form fields. setAuthEmail("ignite@infinite.red") setAuthPassword("ign1teIsAwes0m3") - - // Return a "cleanup" function that React will run when the component unmounts - return () => { - setAuthPassword("") - setAuthEmail("") - } }, [setAuthEmail]) const error = isSubmitted ? validationError : "" @@ -132,7 +123,7 @@ export const LoginScreen: FC = observer(function LoginScreen(_ /> ) -}) +} const $screenContentContainer: ThemedStyle = ({ spacing }) => ({ paddingVertical: spacing.xxl, diff --git a/boilerplate/app/screens/WelcomeScreen.tsx b/boilerplate/app/screens/WelcomeScreen.tsx index bce9a2bb8..2b24fbcab 100644 --- a/boilerplate/app/screens/WelcomeScreen.tsx +++ b/boilerplate/app/screens/WelcomeScreen.tsx @@ -1,4 +1,3 @@ -import { observer } from "mobx-react-lite" // @mst remove-current-line import { FC } from "react" import { Image, ImageStyle, TextStyle, View, ViewStyle } from "react-native" @@ -8,81 +7,74 @@ import { Screen, } from "@/components" import { isRTL } from "@/i18n" -import { useStores } from "@/models" // @demo remove-current-line import { AppStackScreenProps } from "@/navigators" import { $styles, type ThemedStyle } from "@/theme" import { useHeader } from "@/utils/useHeader" // @demo remove-current-line import { useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle" import { useAppTheme } from "@/utils/useAppTheme" +import { useAuth } from "@/context/AuthContext" const welcomeLogo = require("@assets/images/logo.png") const welcomeFace = require("@assets/images/welcome-face.png") interface WelcomeScreenProps extends AppStackScreenProps<"Welcome"> {} -// @mst replace-next-line export const WelcomeScreen: FC = ( -export const WelcomeScreen: FC = observer( - function WelcomeScreen( // @mst remove-current-line - _props, // @demo remove-current-line - // @mst replace-next-line ) => { - ) { - const { themed, theme } = useAppTheme() - // @demo remove-block-start - const { navigation } = _props - const { - authenticationStore: { logout }, - } = useStores() +export const WelcomeScreen: FC = ( + _props, // @demo remove-current-line +) => { + const { themed, theme } = useAppTheme() + // @demo remove-block-start + const { navigation } = _props + const { logout } = useAuth() - function goNext() { - navigation.navigate("Demo", { screen: "DemoShowroom", params: {} }) - } + function goNext() { + navigation.navigate("Demo", { screen: "DemoShowroom", params: {} }) + } - useHeader( - { - rightTx: "common:logOut", - onRightPress: logout, - }, - [logout], - ) - // @demo remove-block-end + useHeader( + { + rightTx: "common:logOut", + onRightPress: logout, + }, + [logout], + ) + // @demo remove-block-end - const $bottomContainerInsets = useSafeAreaInsetsStyle(["bottom"]) + const $bottomContainerInsets = useSafeAreaInsetsStyle(["bottom"]) - return ( - - - - - - - + return ( + + + + + + + - - - {/* @demo remove-block-start */} -