├── config └── index.ts ├── hooks ├── useColorScheme.ts ├── useRedux.ts ├── useColorScheme.web.ts ├── useThemeColor.ts └── useStorageState.ts ├── app ├── (app) │ ├── (tabs) │ │ ├── (home) │ │ │ ├── _layout.tsx │ │ │ ├── rtk.tsx │ │ │ ├── rtkEntity.tsx │ │ │ ├── normalize.tsx │ │ │ └── index.tsx │ │ ├── _layout.tsx │ │ └── explore.tsx │ └── _layout.tsx ├── _layout.tsx ├── +not-found.tsx └── login.tsx ├── assets ├── images │ ├── icon.png │ ├── favicon.png │ ├── react-logo.png │ ├── splash-icon.png │ ├── adaptive-icon.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ └── partial-react-logo.png └── fonts │ └── SpaceMono-Regular.ttf ├── types └── index.ts ├── components ├── ui │ ├── TabBarBackground.tsx │ ├── IconSymbol.ios.tsx │ ├── TabBarBackground.ios.tsx │ └── IconSymbol.tsx ├── __tests__ │ ├── ThemedText-test.tsx │ └── __snapshots__ │ │ └── ThemedText-test.tsx.snap ├── ThemedView.tsx ├── HapticTab.tsx ├── ExternalLink.tsx ├── HelloWave.tsx ├── Collapsible.tsx ├── ThemedText.tsx └── ParallaxScrollView.tsx ├── providers ├── graphql │ ├── ApolloClient.ts │ └── sample │ │ ├── mutation.tsx │ │ └── query.tsx ├── redux │ ├── query │ │ ├── apiSlice.ts │ │ └── baseQueryWithRefresh.ts │ ├── store.ts │ └── user │ │ ├── rtkSlice.ts │ │ ├── userSlice.ts │ │ ├── rtkEntitySlice.ts │ │ └── entitySlice.ts └── ctx.tsx ├── tsconfig.json ├── .gitignore ├── json-server-to-test-api └── db.json ├── constants └── Colors.ts ├── api ├── auth.ts └── index.ts ├── app.json ├── package.json ├── scripts └── reset-project.js └── README.md /config/index.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = "http://localhost:8080/"; 2 | -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/(home)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from "expo-router"; 2 | 3 | export default Stack; 4 | -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/splash-icon.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bonekyaw/react-native-expo/HEAD/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /types/index.ts: -------------------------------------------------------------------------------- 1 | interface PropType { 2 | id: string; 3 | name: string; 4 | } 5 | 6 | export interface User extends PropType {} 7 | 8 | export interface Product extends PropType {} 9 | -------------------------------------------------------------------------------- /components/ui/TabBarBackground.tsx: -------------------------------------------------------------------------------- 1 | // This is a shim for web and Android where the tab bar is generally opaque. 2 | export default undefined; 3 | 4 | export function useBottomTabOverflow() { 5 | return 0; 6 | } 7 | -------------------------------------------------------------------------------- /providers/graphql/ApolloClient.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache } from "@apollo/client"; 2 | 3 | const client = new ApolloClient({ 4 | uri: "http://localhost:8080/graphql", 5 | cache: new InMemoryCache(), 6 | }); 7 | 8 | export default client; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /components/__tests__/ThemedText-test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { ThemedText } from '../ThemedText'; 5 | 6 | it(`renders correctly`, () => { 7 | const tree = renderer.create(Snapshot test!).toJSON(); 8 | 9 | expect(tree).toMatchSnapshot(); 10 | }); 11 | -------------------------------------------------------------------------------- /hooks/useRedux.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux"; 2 | import type { AppDispatch, RootState } from "@/providers/redux/store"; 3 | 4 | export const useAppDispatch = useDispatch.withTypes(); 5 | export const useAppSelector = useSelector.withTypes(); 6 | // export const useAppSelector: TypedUseSelectorHook = useSelector; 7 | -------------------------------------------------------------------------------- /providers/redux/query/apiSlice.ts: -------------------------------------------------------------------------------- 1 | import { createApi } from "@reduxjs/toolkit/query/react"; 2 | import { baseQueryWithRefresh } from "@/providers/redux/query/baseQueryWithRefresh"; 3 | 4 | export const apiSlice = createApi({ 5 | reducerPath: "api", 6 | baseQuery: baseQueryWithRefresh, //staggeredBaseQuery, 7 | tagTypes: ["Users", "Products"], 8 | endpoints: (builder) => ({}), 9 | }); 10 | -------------------------------------------------------------------------------- /components/__tests__/__snapshots__/ThemedText-test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders correctly 1`] = ` 4 | 22 | Snapshot test! 23 | 24 | `; 25 | -------------------------------------------------------------------------------- /components/ThemedView.tsx: -------------------------------------------------------------------------------- 1 | import { View, type ViewProps } from 'react-native'; 2 | 3 | import { useThemeColor } from '@/hooks/useThemeColor'; 4 | 5 | export type ThemedViewProps = ViewProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | }; 9 | 10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { 11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "expo-router"; 2 | import { RootSiblingParent } from "react-native-root-siblings"; 3 | 4 | import { SessionProvider } from "@/providers/ctx"; 5 | import { Provider } from "react-redux"; 6 | import { store } from "@/providers/redux/store"; 7 | 8 | export default function Root() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /hooks/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useColorScheme as useRNColorScheme } from 'react-native'; 3 | 4 | /** 5 | * To support static rendering, this value needs to be re-calculated on the client side for web 6 | */ 7 | export function useColorScheme() { 8 | const [hasHydrated, setHasHydrated] = useState(false); 9 | 10 | useEffect(() => { 11 | setHasHydrated(true); 12 | }, []); 13 | 14 | const colorScheme = useRNColorScheme(); 15 | 16 | if (hasHydrated) { 17 | return colorScheme; 18 | } 19 | 20 | return 'light'; 21 | } 22 | -------------------------------------------------------------------------------- /hooks/useThemeColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about light and dark modes: 3 | * https://docs.expo.dev/guides/color-schemes/ 4 | */ 5 | 6 | import { Colors } from '@/constants/Colors'; 7 | import { useColorScheme } from '@/hooks/useColorScheme'; 8 | 9 | export function useThemeColor( 10 | props: { light?: string; dark?: string }, 11 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 12 | ) { 13 | const theme = useColorScheme() ?? 'light'; 14 | const colorFromProps = props[theme]; 15 | 16 | if (colorFromProps) { 17 | return colorFromProps; 18 | } else { 19 | return Colors[theme][colorName]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /components/HapticTab.tsx: -------------------------------------------------------------------------------- 1 | import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; 2 | import { PlatformPressable } from '@react-navigation/elements'; 3 | import * as Haptics from 'expo-haptics'; 4 | 5 | export function HapticTab(props: BottomTabBarButtonProps) { 6 | return ( 7 | { 10 | if (process.env.EXPO_OS === 'ios') { 11 | // Add a soft haptic feedback when pressing down on the tabs. 12 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); 13 | } 14 | props.onPressIn?.(ev); 15 | }} 16 | /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | .vscode/ 4 | dist/ 5 | npm-debug.* 6 | *.jks 7 | *.p8 8 | *.p12 9 | *.key 10 | *.mobileprovision 11 | *.orig.* 12 | web-build/ 13 | 14 | # Metro 15 | .metro-health-check* 16 | 17 | # debug 18 | npm-debug.* 19 | yarn-debug.* 20 | yarn-error.* 21 | 22 | # macOS 23 | .DS_Store 24 | *.pem 25 | 26 | # local env files 27 | #.env*.local 28 | 29 | # typescript 30 | *.tsbuildinfo 31 | 32 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 33 | # The following patterns were generated by expo-cli 34 | 35 | expo-env.d.ts 36 | # @end expo-cli 37 | 38 | expo-rn.code-workspace 39 | 40 | # Should include config/ 41 | # Should include json-server-to-test-api/ -------------------------------------------------------------------------------- /components/ui/IconSymbol.ios.tsx: -------------------------------------------------------------------------------- 1 | import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols'; 2 | import { StyleProp, ViewStyle } from 'react-native'; 3 | 4 | export function IconSymbol({ 5 | name, 6 | size = 24, 7 | color, 8 | style, 9 | weight = 'regular', 10 | }: { 11 | name: SymbolViewProps['name']; 12 | size?: number; 13 | color: string; 14 | style?: StyleProp; 15 | weight?: SymbolWeight; 16 | }) { 17 | return ( 18 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /components/ExternalLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'expo-router'; 2 | import { openBrowserAsync } from 'expo-web-browser'; 3 | import { type ComponentProps } from 'react'; 4 | import { Platform } from 'react-native'; 5 | 6 | type Props = Omit, 'href'> & { href: string }; 7 | 8 | export function ExternalLink({ href, ...rest }: Props) { 9 | return ( 10 | { 15 | if (Platform.OS !== 'web') { 16 | // Prevent the default behavior of linking to the default browser on native. 17 | event.preventDefault(); 18 | // Open the link in an in-app browser. 19 | await openBrowserAsync(href); 20 | } 21 | }} 22 | /> 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/ui/TabBarBackground.ios.tsx: -------------------------------------------------------------------------------- 1 | import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; 2 | import { BlurView } from 'expo-blur'; 3 | import { StyleSheet } from 'react-native'; 4 | import { useSafeAreaInsets } from 'react-native-safe-area-context'; 5 | 6 | export default function BlurTabBarBackground() { 7 | return ( 8 | 15 | ); 16 | } 17 | 18 | export function useBottomTabOverflow() { 19 | const tabHeight = useBottomTabBarHeight(); 20 | const { bottom } = useSafeAreaInsets(); 21 | return tabHeight - bottom; 22 | } 23 | -------------------------------------------------------------------------------- /json-server-to-test-api/db.json: -------------------------------------------------------------------------------- 1 | { 2 | "users": [ 3 | { 4 | "id": "uuid1", 5 | "name": "Phone Nyo 185728" 6 | }, 7 | { 8 | "id": "uuid2", 9 | "name": "Ko Nay 22" 10 | }, 11 | { 12 | "id": "uuid3", 13 | "name": "Jue Jue " 14 | }, 15 | { 16 | "id": "uuid4", 17 | "name": "Mi Khant " 18 | }, 19 | { 20 | "id": "uuid5", 21 | "name": "Nant Su " 22 | } 23 | ], 24 | "products": [ 25 | { 26 | "id": "uuid1", 27 | "name": "iPhone 5859" 28 | }, 29 | { 30 | "id": "uuid2", 31 | "name": "iPad " 32 | }, 33 | { 34 | "id": "uuid3", 35 | "name": "iMac " 36 | }, 37 | { 38 | "id": "uuid4", 39 | "name": "iWatch " 40 | }, 41 | { 42 | "id": "uuid5", 43 | "name": "Mac book Pro " 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode. 3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. 4 | */ 5 | 6 | const tintColorLight = "#0a7ea4"; 7 | const tintColorDark = "#fff"; 8 | 9 | export const Colors = { 10 | light: { 11 | text: "#11181C", 12 | background: "#fff", 13 | tint: tintColorLight, 14 | icon: "#687076", 15 | tabIconDefault: "#687076", 16 | tabIconSelected: tintColorLight, 17 | }, 18 | dark: { 19 | text: "#ECEDEE", 20 | background: "#151718", 21 | tint: tintColorDark, 22 | icon: "#9BA1A6", 23 | tabIconDefault: "#9BA1A6", 24 | tabIconSelected: tintColorDark, 25 | }, 26 | own: tintColorLight, 27 | ownLight: "#54C1D6", 28 | }; 29 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from 'expo-router'; 2 | import { StyleSheet } from 'react-native'; 3 | 4 | import { ThemedText } from '@/components/ThemedText'; 5 | import { ThemedView } from '@/components/ThemedView'; 6 | 7 | export default function NotFoundScreen() { 8 | return ( 9 | <> 10 | 11 | 12 | This screen doesn't exist. 13 | 14 | Go to home screen! 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | const styles = StyleSheet.create({ 22 | container: { 23 | flex: 1, 24 | alignItems: 'center', 25 | justifyContent: 'center', 26 | padding: 20, 27 | }, 28 | link: { 29 | marginTop: 15, 30 | paddingVertical: 15, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /api/auth.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from "@/config"; 2 | import Toast from "react-native-root-toast"; 3 | 4 | export const fetchAuthApi = async (endpoint = "", data = {}) => { 5 | const url = API_URL + endpoint; 6 | const method = "POST"; 7 | const headers = { 8 | accept: "application/json", 9 | "Content-Type": "application/json", 10 | }; 11 | const options = { method, headers, body: JSON.stringify(data) }; 12 | 13 | try { 14 | const response = await fetch(url, options); 15 | if (!response.ok) { 16 | const res = await response.json(); 17 | console.log("Error Response not ok--------", res); 18 | Toast.show(res.message, { duration: Toast.durations.LONG }); 19 | return null; 20 | } 21 | 22 | return response.json(); 23 | } catch (error: any) { 24 | // console.error("Failed to fetch APi: ", error); 25 | Toast.show("Server Error. Please try again.", { 26 | duration: Toast.durations.LONG, 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-rn", 4 | "slug": "expo-rn", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true 13 | }, 14 | "android": { 15 | "adaptiveIcon": { 16 | "foregroundImage": "./assets/images/adaptive-icon.png", 17 | "backgroundColor": "#ffffff" 18 | } 19 | }, 20 | "web": { 21 | "bundler": "metro", 22 | "output": "static", 23 | "favicon": "./assets/images/favicon.png" 24 | }, 25 | "plugins": [ 26 | "expo-router", 27 | [ 28 | "expo-splash-screen", 29 | { 30 | "image": "./assets/images/splash-icon.png", 31 | "imageWidth": 200, 32 | "resizeMode": "contain", 33 | "backgroundColor": "#ffffff" 34 | } 35 | ], 36 | "expo-secure-store" 37 | ], 38 | "experiments": { 39 | "typedRoutes": true 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /components/HelloWave.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import Animated, { 4 | useSharedValue, 5 | useAnimatedStyle, 6 | withTiming, 7 | withRepeat, 8 | withSequence, 9 | } from 'react-native-reanimated'; 10 | 11 | import { ThemedText } from '@/components/ThemedText'; 12 | 13 | export function HelloWave() { 14 | const rotationAnimation = useSharedValue(0); 15 | 16 | useEffect(() => { 17 | rotationAnimation.value = withRepeat( 18 | withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })), 19 | 4 // Run the animation 4 times 20 | ); 21 | }, []); 22 | 23 | const animatedStyle = useAnimatedStyle(() => ({ 24 | transform: [{ rotate: `${rotationAnimation.value}deg` }], 25 | })); 26 | 27 | return ( 28 | 29 | 👋 30 | 31 | ); 32 | } 33 | 34 | const styles = StyleSheet.create({ 35 | text: { 36 | fontSize: 28, 37 | lineHeight: 32, 38 | marginTop: -6, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /providers/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; 2 | import { setupListeners } from "@reduxjs/toolkit/query"; 3 | 4 | import userReducer from "@/providers/redux/user/userSlice"; 5 | import usersEntityReducer from "@/providers/redux/user/entitySlice"; 6 | import { apiSlice } from "@/providers/redux/query/apiSlice"; 7 | 8 | export const store = configureStore({ 9 | reducer: { 10 | [apiSlice.reducerPath]: apiSlice.reducer, 11 | users: userReducer, 12 | usersEntity: usersEntityReducer, 13 | }, 14 | middleware: (getDefaultMiddleware) => 15 | getDefaultMiddleware().concat(apiSlice.middleware), 16 | }); 17 | 18 | // optional, but required for refetchOnFocus/refetchOnReconnect behaviors 19 | // see `setupListeners` docs - takes an optional callback as the 2nd arg for customization 20 | setupListeners(store.dispatch); 21 | 22 | export type RootState = ReturnType; 23 | export type AppDispatch = typeof store.dispatch; 24 | export type AppThunk = ThunkAction< 25 | ReturnType, 26 | RootState, 27 | unknown, 28 | Action 29 | >; 30 | -------------------------------------------------------------------------------- /components/Collapsible.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from 'react'; 2 | import { StyleSheet, TouchableOpacity } from 'react-native'; 3 | 4 | import { ThemedText } from '@/components/ThemedText'; 5 | import { ThemedView } from '@/components/ThemedView'; 6 | import { IconSymbol } from '@/components/ui/IconSymbol'; 7 | import { Colors } from '@/constants/Colors'; 8 | import { useColorScheme } from '@/hooks/useColorScheme'; 9 | 10 | export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { 11 | const [isOpen, setIsOpen] = useState(false); 12 | const theme = useColorScheme() ?? 'light'; 13 | 14 | return ( 15 | 16 | setIsOpen((value) => !value)} 19 | activeOpacity={0.8}> 20 | 27 | 28 | {title} 29 | 30 | {isOpen && {children}} 31 | 32 | ); 33 | } 34 | 35 | const styles = StyleSheet.create({ 36 | heading: { 37 | flexDirection: 'row', 38 | alignItems: 'center', 39 | gap: 6, 40 | }, 41 | content: { 42 | marginTop: 6, 43 | marginLeft: 24, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /components/ThemedText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, type TextProps, StyleSheet } from "react-native"; 2 | 3 | import { useThemeColor } from "@/hooks/useThemeColor"; 4 | 5 | export type ThemedTextProps = TextProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link"; 9 | }; 10 | 11 | export function ThemedText({ 12 | style, 13 | lightColor, 14 | darkColor, 15 | type = "default", 16 | ...rest 17 | }: ThemedTextProps) { 18 | const color = useThemeColor({ light: lightColor, dark: darkColor }, "text"); 19 | 20 | return ( 21 | 34 | ); 35 | } 36 | 37 | const styles = StyleSheet.create({ 38 | default: { 39 | fontSize: 16, 40 | lineHeight: 24, 41 | }, 42 | defaultSemiBold: { 43 | fontSize: 16, 44 | lineHeight: 24, 45 | fontWeight: "600", 46 | }, 47 | title: { 48 | fontSize: 32, 49 | fontWeight: "bold", 50 | lineHeight: 32, 51 | }, 52 | subtitle: { 53 | fontSize: 20, 54 | fontWeight: "bold", 55 | }, 56 | link: { 57 | lineHeight: 30, 58 | fontSize: 16, 59 | color: "#0a7ea4", 60 | }, 61 | }); 62 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "expo-router"; 2 | import React from "react"; 3 | import { Platform } from "react-native"; 4 | 5 | import { HapticTab } from "@/components/HapticTab"; 6 | import { IconSymbol } from "@/components/ui/IconSymbol"; 7 | import TabBarBackground from "@/components/ui/TabBarBackground"; 8 | import { Colors } from "@/constants/Colors"; 9 | import { useColorScheme } from "@/hooks/useColorScheme"; 10 | 11 | export default function TabLayout() { 12 | const colorScheme = useColorScheme(); 13 | 14 | return ( 15 | 30 | ( 35 | 36 | ), 37 | }} 38 | /> 39 | ( 44 | 45 | ), 46 | }} 47 | /> 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /components/ui/IconSymbol.tsx: -------------------------------------------------------------------------------- 1 | // This file is a fallback for using MaterialIcons on Android and web. 2 | 3 | import MaterialIcons from "@expo/vector-icons/MaterialIcons"; 4 | import { SymbolWeight } from "expo-symbols"; 5 | import React from "react"; 6 | import { OpaqueColorValue, StyleProp, ViewStyle } from "react-native"; 7 | 8 | // Add your SFSymbol to MaterialIcons mappings here. 9 | const MAPPING = { 10 | // See MaterialIcons here: https://icons.expo.fyi 11 | // See SF Symbols in the SF Symbols app on Mac. 12 | "house.fill": "home", 13 | "paperplane.fill": "send", 14 | "chevron.left.forwardslash.chevron.right": "code", 15 | "chevron.right": "chevron-right", 16 | "bell.and.waveform.fill": "add-alert", 17 | } as Partial< 18 | Record< 19 | import("expo-symbols").SymbolViewProps["name"], 20 | React.ComponentProps["name"] 21 | > 22 | >; 23 | 24 | export type IconSymbolName = keyof typeof MAPPING; 25 | 26 | /** 27 | * An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage. 28 | * 29 | * Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons. 30 | */ 31 | export function IconSymbol({ 32 | name, 33 | size = 24, 34 | color, 35 | style, 36 | }: { 37 | name: IconSymbolName; 38 | size?: number; 39 | color: string | OpaqueColorValue; 40 | style?: StyleProp; 41 | weight?: SymbolWeight; 42 | }) { 43 | return ( 44 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/(app)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "react-native"; 2 | import { 3 | DarkTheme, 4 | DefaultTheme, 5 | ThemeProvider, 6 | } from "@react-navigation/native"; 7 | import { useFonts } from "expo-font"; 8 | import { Stack, Redirect } from "expo-router"; 9 | import * as SplashScreen from "expo-splash-screen"; 10 | import { StatusBar } from "expo-status-bar"; 11 | import { useEffect } from "react"; 12 | import "react-native-reanimated"; 13 | 14 | import { useSession } from "@/providers/ctx"; 15 | import { useColorScheme } from "@/hooks/useColorScheme"; 16 | 17 | // Prevent the splash screen from auto-hiding before asset loading is complete. 18 | SplashScreen.preventAutoHideAsync(); 19 | 20 | export default function RootLayout() { 21 | const { session, isLoading, signOut } = useSession(); 22 | const colorScheme = useColorScheme(); 23 | const [loaded] = useFonts({ 24 | SpaceMono: require("../../assets/fonts/SpaceMono-Regular.ttf"), 25 | }); 26 | 27 | useEffect(() => { 28 | if (loaded) { 29 | SplashScreen.hideAsync(); 30 | } 31 | }, [loaded]); 32 | 33 | if (!loaded) { 34 | return null; 35 | } 36 | 37 | // You can keep the splash screen open, or render a loading screen like we do here. 38 | if (isLoading) { 39 | return Loading...; 40 | } 41 | 42 | // Only require authentication within the (app) group's layout as users 43 | // need to be able to access the (auth) group and sign in again. 44 | if (!session) { 45 | return ; 46 | } 47 | 48 | return ( 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /hooks/useStorageState.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, useReducer } from "react"; 2 | import * as SecureStore from "expo-secure-store"; 3 | import { Platform } from "react-native"; 4 | 5 | type UseStateHook = [[boolean, T | null], (value: T | null) => void]; 6 | 7 | function useAsyncState( 8 | initialValue: [boolean, T | null] = [true, null] 9 | ): UseStateHook { 10 | return useReducer( 11 | ( 12 | state: [boolean, T | null], 13 | action: T | null = null 14 | ): [boolean, T | null] => [false, action], 15 | initialValue 16 | ) as UseStateHook; 17 | } 18 | 19 | export async function setStorageItemAsync(key: string, value: string | null) { 20 | if (Platform.OS === "web") { 21 | try { 22 | if (value === null) { 23 | localStorage.removeItem(key); 24 | } else { 25 | localStorage.setItem(key, value); 26 | } 27 | } catch (e) { 28 | console.error("Local storage is unavailable:", e); 29 | } 30 | } else { 31 | if (value == null) { 32 | await SecureStore.deleteItemAsync(key); 33 | } else { 34 | await SecureStore.setItemAsync(key, value); 35 | } 36 | } 37 | } 38 | 39 | export function useStorageState(key: string): UseStateHook { 40 | // Public 41 | const [state, setState] = useAsyncState(); 42 | 43 | // Get 44 | useEffect(() => { 45 | if (Platform.OS === "web") { 46 | try { 47 | if (typeof localStorage !== "undefined") { 48 | setState(localStorage.getItem(key)); 49 | } 50 | } catch (e) { 51 | console.error("Local storage is unavailable:", e); 52 | } 53 | } else { 54 | SecureStore.getItemAsync(key).then((value) => { 55 | setState(value); 56 | }); 57 | } 58 | }, [key]); 59 | 60 | // Set 61 | const setValue = useCallback( 62 | (value: string | null) => { 63 | setState(value); 64 | setStorageItemAsync(key, value); 65 | }, 66 | [key] 67 | ); 68 | 69 | return [state, setValue]; 70 | } 71 | -------------------------------------------------------------------------------- /providers/redux/user/rtkSlice.ts: -------------------------------------------------------------------------------- 1 | import { apiSlice } from "@/providers/redux/query/apiSlice"; 2 | import type { User } from "@/types"; 3 | 4 | export const extendedApiSlice = apiSlice.injectEndpoints({ 5 | endpoints: (builder) => ({ 6 | getUsers: builder.query({ 7 | query: () => "users", 8 | // providesTags: ["Users"], 9 | providesTags: (result, error, arg) => 10 | result 11 | ? [ 12 | ...result.map(({ id }) => ({ type: "Users" as const, id })), 13 | "Users", 14 | ] 15 | : ["Users"], 16 | }), 17 | updateUser: builder.mutation({ 18 | query: (body) => ({ 19 | url: `users/${body.id}`, 20 | method: "PUT", 21 | body, 22 | }), 23 | // invalidatesTags: ["Users"], 24 | // invalidatesTags: (result, error, arg) => [{ type: "Users", id: arg.id }], 25 | async onQueryStarted({ id, name }, { dispatch, queryFulfilled }) { 26 | // `updateQueryData` requires the endpoint name and cache key arguments, 27 | // so it knows which piece of cache state to update 28 | const patchResult = dispatch( 29 | // updateQueryData takes three arguments: the name of the endpoint to update, the same cache key value used to identify the specific cached data, and a callback that updates the cached data. 30 | extendedApiSlice.util.updateQueryData( 31 | "getUsers", 32 | undefined, 33 | (draftUsers) => { 34 | // The `draft` is Immer-wrapped and can be "mutated" like in createSlice 35 | const user = draftUsers.find((user) => user.id === id); 36 | if (user) user.name = name; 37 | } 38 | ) 39 | ); 40 | try { 41 | await queryFulfilled; 42 | } catch { 43 | patchResult.undo(); 44 | } 45 | }, 46 | }), 47 | }), 48 | }); 49 | 50 | export const { useGetUsersQuery, useUpdateUserMutation } = extendedApiSlice; 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-rn", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "lint": "expo lint" 13 | }, 14 | "jest": { 15 | "preset": "jest-expo" 16 | }, 17 | "dependencies": { 18 | "@apollo/client": "^3.12.2", 19 | "@expo/vector-icons": "^14.0.2", 20 | "@react-navigation/bottom-tabs": "^7.0.0", 21 | "@react-navigation/native": "^7.0.0", 22 | "@reduxjs/toolkit": "^2.4.0", 23 | "expo": "~52.0.17", 24 | "expo-blur": "~14.0.1", 25 | "expo-constants": "~17.0.3", 26 | "expo-font": "~13.0.1", 27 | "expo-haptics": "~14.0.0", 28 | "expo-image": "~2.0.3", 29 | "expo-linking": "~7.0.3", 30 | "expo-router": "~4.0.11", 31 | "expo-secure-store": "~14.0.0", 32 | "expo-splash-screen": "~0.29.15", 33 | "expo-status-bar": "~2.0.0", 34 | "expo-symbols": "~0.2.0", 35 | "expo-system-ui": "~4.0.5", 36 | "expo-web-browser": "~14.0.1", 37 | "graphql": "^16.9.0", 38 | "react": "18.3.1", 39 | "react-dom": "18.3.1", 40 | "react-hook-form": "^7.54.0", 41 | "react-native": "0.76.3", 42 | "react-native-gesture-handler": "~2.20.2", 43 | "react-native-reanimated": "~3.16.1", 44 | "react-native-root-toast": "^3.6.0", 45 | "react-native-safe-area-context": "4.12.0", 46 | "react-native-screens": "~4.1.0", 47 | "react-native-web": "~0.19.13", 48 | "react-native-webview": "13.12.5", 49 | "react-redux": "^9.1.2" 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.25.2", 53 | "@types/jest": "^29.5.12", 54 | "@types/react": "~18.3.12", 55 | "@types/react-test-renderer": "^18.3.0", 56 | "jest": "^29.2.1", 57 | "jest-expo": "~52.0.2", 58 | "json-server": "^1.0.0-beta.3", 59 | "react-test-renderer": "18.3.1", 60 | "typescript": "^5.3.3" 61 | }, 62 | "private": true, 63 | "expo": { 64 | "doctor": { 65 | "reactNativeDirectoryCheck": { 66 | "exclude": [ 67 | "react-redux", 68 | "@reduxjs/toolkit", 69 | "@apollo/client", 70 | "graphql" 71 | ] 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /providers/graphql/sample/mutation.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Pressable } from "react-native"; 2 | 3 | import { gql, useMutation } from "@apollo/client"; 4 | 5 | import { ThemedText } from "@/components/ThemedText"; 6 | import { ThemedView } from "@/components/ThemedView"; 7 | 8 | const register = gql` 9 | mutation Register($phone: String!) { 10 | register(phone: $phone) { 11 | message 12 | phone 13 | token 14 | } 15 | } 16 | `; 17 | 18 | export default function MutationScreen() { 19 | const [registerByPhone, { data, loading, error, reset }] = 20 | useMutation(register); 21 | 22 | if (error) { 23 | return ( 24 | Failed to fetch data 25 | ); 26 | } 27 | if (loading) { 28 | return Loading...; 29 | } 30 | 31 | return ( 32 | 33 | registerByPhone({ variables: { phone: "09778661235" } })} 36 | > 37 | 38 | Click To Call graphql API 39 | 40 | 41 | 42 | {data && ( 43 | 44 | 45 | Message : {data.register.message} 46 | 47 | 48 | Phone : {data.register.phone} 49 | 50 | 51 | Token : {data.register.token} 52 | 53 | 54 | )} 55 | 56 | ); 57 | } 58 | 59 | const styles = StyleSheet.create({ 60 | container: { 61 | flex: 1, 62 | justifyContent: "center", 63 | alignItems: "center", 64 | width: "100%", 65 | }, 66 | button: { 67 | borderWidth: 1, 68 | borderColor: "#000", 69 | paddingVertical: 10, 70 | paddingHorizontal: 20, 71 | borderRadius: 5, 72 | shadowColor: "#000", 73 | shadowOffset: { width: 0, height: 2 }, 74 | shadowOpacity: 0.1, 75 | shadowRadius: 5, 76 | // elevation: 3, // For Android shadow 77 | }, 78 | buttonText: { 79 | fontSize: 16, 80 | }, 81 | marginTop15: { 82 | marginTop: 15, 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /components/ParallaxScrollView.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren, ReactElement } from 'react'; 2 | import { StyleSheet } from 'react-native'; 3 | import Animated, { 4 | interpolate, 5 | useAnimatedRef, 6 | useAnimatedStyle, 7 | useScrollViewOffset, 8 | } from 'react-native-reanimated'; 9 | 10 | import { ThemedView } from '@/components/ThemedView'; 11 | import { useBottomTabOverflow } from '@/components/ui/TabBarBackground'; 12 | import { useColorScheme } from '@/hooks/useColorScheme'; 13 | 14 | const HEADER_HEIGHT = 250; 15 | 16 | type Props = PropsWithChildren<{ 17 | headerImage: ReactElement; 18 | headerBackgroundColor: { dark: string; light: string }; 19 | }>; 20 | 21 | export default function ParallaxScrollView({ 22 | children, 23 | headerImage, 24 | headerBackgroundColor, 25 | }: Props) { 26 | const colorScheme = useColorScheme() ?? 'light'; 27 | const scrollRef = useAnimatedRef(); 28 | const scrollOffset = useScrollViewOffset(scrollRef); 29 | const bottom = useBottomTabOverflow(); 30 | const headerAnimatedStyle = useAnimatedStyle(() => { 31 | return { 32 | transform: [ 33 | { 34 | translateY: interpolate( 35 | scrollOffset.value, 36 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 37 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] 38 | ), 39 | }, 40 | { 41 | scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]), 42 | }, 43 | ], 44 | }; 45 | }); 46 | 47 | return ( 48 | 49 | 54 | 60 | {headerImage} 61 | 62 | {children} 63 | 64 | 65 | ); 66 | } 67 | 68 | const styles = StyleSheet.create({ 69 | container: { 70 | flex: 1, 71 | }, 72 | header: { 73 | height: HEADER_HEIGHT, 74 | overflow: 'hidden', 75 | }, 76 | content: { 77 | flex: 1, 78 | padding: 32, 79 | gap: 16, 80 | overflow: 'hidden', 81 | }, 82 | }); 83 | -------------------------------------------------------------------------------- /providers/redux/user/userSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; 2 | import type { PayloadAction } from "@reduxjs/toolkit"; 3 | 4 | import { fetchApi } from "@/api"; 5 | import type { User } from "@/types"; 6 | 7 | interface initialStateType { 8 | users: User[]; 9 | isFetching: boolean; 10 | } 11 | 12 | const initialState: initialStateType = { 13 | users: [], 14 | isFetching: false, 15 | }; 16 | 17 | export const getUsers = createAsyncThunk( 18 | "users/getUsers", 19 | async (_, { rejectWithValue }) => { 20 | const response = await fetchApi("users"); 21 | if (!response) { 22 | return rejectWithValue("Network Connection failed. Please try again."); 23 | } 24 | if (response.error === "Error_Attack") { 25 | // Error_Attack - Must Log Out 26 | return rejectWithValue(response.error); 27 | } 28 | if (response.error) { 29 | return rejectWithValue(response.message); 30 | } 31 | return response; 32 | } 33 | ); 34 | 35 | export const updateUser = createAsyncThunk( 36 | "users/updateUser", 37 | async (user: User, { rejectWithValue }) => { 38 | const response = await fetchApi(`users/${user.id}`, "PUT", user); 39 | if (!response) { 40 | return rejectWithValue("Network Connection failed. Please try again."); 41 | } 42 | if (response.error === "Error_Attack") { 43 | // Error_Attack - Must Log Out 44 | return rejectWithValue(response.error); 45 | } 46 | if (response.error) { 47 | return rejectWithValue(response.message); 48 | } 49 | return response; 50 | } 51 | ); 52 | 53 | const userSlice = createSlice({ 54 | name: "users", 55 | initialState, 56 | reducers: {}, 57 | extraReducers: (builder) => { 58 | builder.addCase(getUsers.pending, (state) => { 59 | state.isFetching = true; 60 | }); 61 | builder.addCase( 62 | getUsers.fulfilled, 63 | (state, action: PayloadAction) => { 64 | state.users = action.payload; 65 | state.isFetching = false; 66 | } 67 | ); 68 | builder.addCase(getUsers.rejected, (state) => { 69 | state.isFetching = false; 70 | }); 71 | builder.addCase( 72 | updateUser.fulfilled, 73 | (state, action: PayloadAction) => { 74 | const { id, name } = action.payload; 75 | const existingUser = state.users.find((user) => user.id === id); 76 | if (existingUser) { 77 | existingUser.name = name; 78 | } 79 | } 80 | ); 81 | }, 82 | }); 83 | 84 | export const {} = userSlice.actions; 85 | 86 | export default userSlice.reducer; 87 | -------------------------------------------------------------------------------- /providers/redux/query/baseQueryWithRefresh.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseQueryFn, 3 | FetchArgs, 4 | FetchBaseQueryError, 5 | fetchBaseQuery, 6 | } from "@reduxjs/toolkit/query/react"; 7 | import { API_URL } from "@/config"; 8 | import * as SecureStore from "expo-secure-store"; 9 | 10 | interface RefreshTokenResponse { 11 | token: string; 12 | refreshToken: string; 13 | randToken: string; 14 | } 15 | 16 | export const baseQueryWithRefresh: BaseQueryFn< 17 | string | FetchArgs, // args can be a URL string or an object with more options 18 | unknown, // success type of the response 19 | FetchBaseQueryError // error type 20 | > = async (args, api, extraOptions) => { 21 | const baseQuery = fetchBaseQuery({ 22 | baseUrl: API_URL, 23 | prepareHeaders: async (headers) => { 24 | const token = await SecureStore.getItemAsync("token"); 25 | if (token) { 26 | headers.set("Authorization", `Bearer ${token}`); 27 | headers.set("Content-Type", "application/json"); 28 | } 29 | return headers; 30 | }, 31 | }); 32 | 33 | let result = await baseQuery(args, api, extraOptions); 34 | // console.log("RTK query--", result.data); 35 | 36 | if (result.error && result.error.status === 401) { 37 | // response.status == 401 && res.error === "Error_AccessTokenExpired" 38 | console.log("Access token expired, attempting to refresh...", result); 39 | const refreshToken = await SecureStore.getItemAsync("refreshToken"); 40 | const randToken = await SecureStore.getItemAsync("randToken"); 41 | 42 | if (refreshToken) { 43 | const refreshResult = await baseQuery( 44 | { 45 | url: "refresh-token", 46 | method: "POST", 47 | body: JSON.stringify({ 48 | refreshToken: refreshToken, 49 | randToken: randToken, 50 | }), 51 | }, 52 | api, 53 | extraOptions 54 | ); 55 | 56 | if (refreshResult.data) { 57 | const data = refreshResult.data as RefreshTokenResponse; 58 | console.log("Refresh response data -----", data); 59 | 60 | await SecureStore.setItemAsync("token", data.token); 61 | await SecureStore.setItemAsync("refreshToken", data.refreshToken); 62 | await SecureStore.setItemAsync("randToken", data.randToken); 63 | 64 | // Retry the original query 65 | result = await baseQuery(args, api, extraOptions); 66 | } else { 67 | //api.dispatch(logout()); // Logout logic 68 | console.log("RTK query error 1"); 69 | } 70 | } else { 71 | //api.dispatch(logout()); 72 | console.log("RTK query error 2"); 73 | } 74 | } 75 | 76 | return result; 77 | }; 78 | -------------------------------------------------------------------------------- /providers/graphql/sample/query.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, FlatList } from "react-native"; 2 | 3 | import { useQuery, gql } from "@apollo/client"; 4 | 5 | import { ThemedText } from "@/components/ThemedText"; 6 | import { ThemedView } from "@/components/ThemedView"; 7 | 8 | const paginate = gql` 9 | query PaginateAdmins($limit: Int!, $page: Int!) { 10 | paginateAdmins(limit: $limit, page: $page) { 11 | total 12 | data { 13 | _id 14 | name 15 | phone 16 | role 17 | status 18 | lastLogin 19 | profile 20 | createdAt 21 | } 22 | pageInfo { 23 | currentPage 24 | previousPage 25 | nextPage 26 | lastPage 27 | countPerPage 28 | nextCursor 29 | hasNextPage 30 | } 31 | } 32 | } 33 | `; 34 | 35 | export default function QueryScreen() { 36 | // Hey, you can save token in global state or secure store after login. 37 | // And then it can be taken anywhere. 38 | 39 | const token = 40 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY2NGM3MmVmOWRjOTI5NDI1ZDg1NjdiMSIsImlhdCI6MTcxODUxNjQ0NywiZXhwIjoxNzE4NTIwMDQ3fQ.vrIeajcYNk4KueR4roK78f2Bc_yqYjfR9eIXtYg7Ht8"; 41 | 42 | const { loading, error, data } = useQuery(paginate, { 43 | variables: { limit: 3, page: 1 }, 44 | context: { 45 | headers: { 46 | Authorization: `Bearer ${token}`, 47 | }, 48 | }, 49 | }); 50 | 51 | if (error) { 52 | return ( 53 | Failed to fetch data 54 | ); 55 | } 56 | if (loading) { 57 | return Loading...; 58 | } 59 | 60 | const Item = ({ item }: any) => ( 61 | 62 | ID : {item._id} 63 | Phone : {item.phone} 64 | Role : {item.role} 65 | 66 | ---------------------------- 67 | 68 | 69 | ); 70 | 71 | return ( 72 | 73 | {data && ( 74 | } 77 | keyExtractor={(item) => item._id} 78 | /> 79 | )} 80 | 81 | ); 82 | } 83 | 84 | const styles = StyleSheet.create({ 85 | container: { 86 | flex: 1, 87 | justifyContent: "center", 88 | alignItems: "center", 89 | width: "100%", 90 | }, 91 | marginTop5: { 92 | marginTop: 15, 93 | }, 94 | }); 95 | -------------------------------------------------------------------------------- /providers/ctx.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, createContext, type PropsWithChildren } from "react"; 2 | import { useStorageState } from "@/hooks/useStorageState"; 3 | import { fetchAuthApi } from "@/api/auth"; 4 | import * as SecureStore from "expo-secure-store"; 5 | 6 | const AuthContext = createContext<{ 7 | signIn: ({}) => void; 8 | signOut: () => void; 9 | session?: string | null; 10 | isLoading: boolean; 11 | }>({ 12 | signIn: () => null, 13 | signOut: () => null, 14 | session: null, 15 | isLoading: false, 16 | }); 17 | 18 | // This hook can be used to access the user info. 19 | export function useSession() { 20 | const value = useContext(AuthContext); 21 | if (process.env.NODE_ENV !== "production") { 22 | if (!value) { 23 | throw new Error("useSession must be wrapped in a "); 24 | } 25 | } 26 | 27 | return value; 28 | } 29 | 30 | export function SessionProvider({ children }: PropsWithChildren) { 31 | const [[isLoading, session], setSession] = useStorageState("session"); 32 | 33 | return ( 34 | { 37 | setSession("xxx"); // set session string as you like 38 | // Perform sign-in logic here 39 | // console.log("Login Data ---------", formState); 40 | // try { 41 | // const response: any = await fetchAuthApi("login", formState); // Call Auth api 42 | // if (response) { 43 | // console.log("Login Response ----- ", response); 44 | 45 | // // store token and user info into secure storage or mmkv 46 | // setSession("xxx"); // set session string as you like 47 | // await SecureStore.setItemAsync("token", response.token); 48 | // await SecureStore.setItemAsync( 49 | // "refreshToken", 50 | // response.refreshToken 51 | // ); 52 | // await SecureStore.setItemAsync("randToken", response.randToken); 53 | // } 54 | // } catch (err) { 55 | // console.error("Failed in ctx: ", err); 56 | // } 57 | }, 58 | signOut: async () => { 59 | setSession(null); 60 | // try { 61 | // await SecureStore.deleteItemAsync("token"); 62 | // await SecureStore.deleteItemAsync("refreshToken"); 63 | // await SecureStore.deleteItemAsync("randToken"); 64 | // setSession(null); 65 | // } catch (error) { 66 | // console.error("Failed in ctx: ", error); 67 | // } 68 | }, 69 | session, 70 | isLoading, 71 | }} 72 | > 73 | {children} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /providers/redux/user/rtkEntitySlice.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter } from "@reduxjs/toolkit"; 2 | 3 | import { apiSlice } from "@/providers/redux/query/apiSlice"; 4 | import type { Product } from "@/types"; 5 | 6 | const productsAdapter = createEntityAdapter({ 7 | // sortComparer: (a, b) => b.date.localeCompare(a.date) 8 | }); 9 | 10 | const initialState = productsAdapter.getInitialState(); 11 | 12 | export const entityApiSlice = apiSlice.injectEndpoints({ 13 | endpoints: (builder) => ({ 14 | getProducts: builder.query({ 15 | query: () => "products", 16 | extraOptions: { maxRetries: 8 }, // Override 17 | transformResponse: (response, meta, arg) => { 18 | // console.log("Fetching All users --------"); 19 | if (!Array.isArray(response)) { 20 | return productsAdapter.setAll(initialState, []); 21 | } 22 | // console.log("Response Products --------", response); 23 | return productsAdapter.setAll(initialState, response); 24 | }, 25 | // providesTags: ["Users"], 26 | providesTags: (result: any, error, arg) => [ 27 | { type: "Products" as const, id: "LIST" }, 28 | ...result.ids.map((id: any) => ({ type: "Products", id } as const)), 29 | ], 30 | }), 31 | updateProduct: builder.mutation({ 32 | query: (body) => ({ 33 | url: `products/${body.id}`, 34 | method: "PUT", 35 | body, 36 | }), 37 | // invalidatesTags: ["Users"], 38 | // invalidatesTags: (result, error, arg) => [{ type: "Users", id: arg.id }], 39 | async onQueryStarted({ id, name }, { dispatch, queryFulfilled }) { 40 | // `updateQueryData` requires the endpoint name and cache key arguments, 41 | // so it knows which piece of cache state to update 42 | const patchResult = dispatch( 43 | // updateQueryData takes three arguments: the name of the endpoint to update, the same cache key value used to identify the specific cached data, and a callback that updates the cached data. 44 | entityApiSlice.util.updateQueryData( 45 | "getProducts", 46 | "getProducts", 47 | (draftProducts) => { 48 | // The `draft` is Immer-wrapped and can be "mutated" like in createSlice 49 | // const user = draftUsers.find((user) => user.id === id); 50 | // if (user) user.name = name; 51 | const product = draftProducts.entities[id]; 52 | if (product) product.name = name; 53 | } 54 | ) 55 | ); 56 | try { 57 | await queryFulfilled; 58 | } catch { 59 | patchResult.undo(); 60 | } 61 | }, 62 | }), 63 | }), 64 | }); 65 | 66 | export const { useGetProductsQuery, useUpdateProductMutation } = entityApiSlice; 67 | -------------------------------------------------------------------------------- /scripts/reset-project.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * This script is used to reset the project to a blank state. 5 | * It moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file. 6 | * You can remove the `reset-project` script from package.json and safely delete this file after running it. 7 | */ 8 | 9 | const fs = require("fs"); 10 | const path = require("path"); 11 | 12 | const root = process.cwd(); 13 | const oldDirs = ["app", "components", "hooks", "constants", "scripts"]; 14 | const newDir = "app-example"; 15 | const newAppDir = "app"; 16 | const newDirPath = path.join(root, newDir); 17 | 18 | const indexContent = `import { Text, View } from "react-native"; 19 | 20 | export default function Index() { 21 | return ( 22 | 29 | Edit app/index.tsx to edit this screen. 30 | 31 | ); 32 | } 33 | `; 34 | 35 | const layoutContent = `import { Stack } from "expo-router"; 36 | 37 | export default function RootLayout() { 38 | return ; 39 | } 40 | `; 41 | 42 | const moveDirectories = async () => { 43 | try { 44 | // Create the app-example directory 45 | await fs.promises.mkdir(newDirPath, { recursive: true }); 46 | console.log(`📁 /${newDir} directory created.`); 47 | 48 | // Move old directories to new app-example directory 49 | for (const dir of oldDirs) { 50 | const oldDirPath = path.join(root, dir); 51 | const newDirPath = path.join(root, newDir, dir); 52 | if (fs.existsSync(oldDirPath)) { 53 | await fs.promises.rename(oldDirPath, newDirPath); 54 | console.log(`➡️ /${dir} moved to /${newDir}/${dir}.`); 55 | } else { 56 | console.log(`➡️ /${dir} does not exist, skipping.`); 57 | } 58 | } 59 | 60 | // Create new /app directory 61 | const newAppDirPath = path.join(root, newAppDir); 62 | await fs.promises.mkdir(newAppDirPath, { recursive: true }); 63 | console.log("\n📁 New /app directory created."); 64 | 65 | // Create index.tsx 66 | const indexPath = path.join(newAppDirPath, "index.tsx"); 67 | await fs.promises.writeFile(indexPath, indexContent); 68 | console.log("📄 app/index.tsx created."); 69 | 70 | // Create _layout.tsx 71 | const layoutPath = path.join(newAppDirPath, "_layout.tsx"); 72 | await fs.promises.writeFile(layoutPath, layoutContent); 73 | console.log("📄 app/_layout.tsx created."); 74 | 75 | console.log("\n✅ Project reset complete. Next steps:"); 76 | console.log( 77 | "1. Run `npx expo start` to start a development server.\n2. Edit app/index.tsx to edit the main screen.\n3. Delete the /app-example directory when you're done referencing it." 78 | ); 79 | } catch (error) { 80 | console.error(`Error during script execution: ${error}`); 81 | } 82 | }; 83 | 84 | moveDirectories(); 85 | -------------------------------------------------------------------------------- /providers/redux/user/entitySlice.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSlice, 3 | createAsyncThunk, 4 | createEntityAdapter, 5 | } from "@reduxjs/toolkit"; 6 | import type { PayloadAction } from "@reduxjs/toolkit"; 7 | 8 | import type { RootState } from "@/providers/redux/store"; 9 | import { fetchApi } from "@/api"; 10 | import type { User } from "@/types"; 11 | 12 | export const getUsersEntity = createAsyncThunk( 13 | "userEntity/getUsers", 14 | async (_, { rejectWithValue }) => { 15 | const response = await fetchApi("users"); 16 | if (!response) { 17 | return rejectWithValue("Network connection failed. Please try again!"); 18 | } 19 | if (response.error === "Error_Attack") { 20 | // Error_Attack - Must Log Out 21 | return rejectWithValue(response.error); 22 | } 23 | if (response.error) { 24 | return rejectWithValue(response.message); 25 | } 26 | 27 | return response; 28 | } 29 | ); 30 | 31 | export const updateUserEntity = createAsyncThunk( 32 | "userEntity/updateUser", 33 | async (user: User, { rejectWithValue }) => { 34 | const response = await fetchApi(`users/${user.id}`, "PUT", user); 35 | if (!response) { 36 | return rejectWithValue("Network connection failed. Please try again!"); 37 | } 38 | if (response.error === "Error_Attack") { 39 | // Error_Attack - Must Log Out 40 | return rejectWithValue(response.error); 41 | } 42 | if (response.error) { 43 | return rejectWithValue(response.message); 44 | } 45 | 46 | return response; 47 | } 48 | ); 49 | 50 | export const usersAdapter = createEntityAdapter(); 51 | 52 | const initialState = usersAdapter.getInitialState({ 53 | loading: false, 54 | error: false, 55 | }); 56 | 57 | export const userEntitySlice = createSlice({ 58 | name: "usersEntity", 59 | initialState, 60 | reducers: { 61 | // removeUser: usersAdapter.removeOne, 62 | updateUser: usersAdapter.updateOne, 63 | }, 64 | extraReducers: (builder) => { 65 | builder.addCase(getUsersEntity.pending, (state) => { 66 | state.loading = true; 67 | state.error = false; 68 | }); 69 | builder.addCase( 70 | getUsersEntity.fulfilled, 71 | (state, action: PayloadAction) => { 72 | usersAdapter.setAll(state, action.payload); 73 | state.error = false; 74 | state.loading = false; 75 | } 76 | ); 77 | builder.addCase(getUsersEntity.rejected, (state) => { 78 | state.loading = false; 79 | state.error = true; 80 | }); 81 | builder.addCase( 82 | updateUserEntity.fulfilled, 83 | (state, action: PayloadAction) => { 84 | usersAdapter.updateOne(state, { 85 | id: action.payload.id, 86 | changes: { name: action.payload.name }, 87 | }); 88 | } 89 | ); 90 | }, 91 | }); 92 | 93 | const reducer = userEntitySlice.reducer; 94 | export default reducer; 95 | 96 | export const { updateUser } = userEntitySlice.actions; 97 | 98 | export const error = (state: RootState) => state.usersEntity.error; 99 | export const loading = (state: RootState) => state.usersEntity.loading; 100 | 101 | export const { 102 | selectById: selectUserById, 103 | selectIds: selectUserIds, 104 | selectEntities: selectUserEntities, 105 | selectAll: selectAllUsers, 106 | selectTotal: selectTotalUsers, 107 | } = usersAdapter.getSelectors((state: RootState) => state.usersEntity); 108 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/(home)/rtk.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, memo } from "react"; 2 | import { StyleSheet, ActivityIndicator, Pressable } from "react-native"; 3 | import { Link, Stack } from "expo-router"; 4 | import { useFocusEffect } from "@react-navigation/native"; 5 | 6 | import { IconSymbol } from "@/components/ui/IconSymbol"; 7 | import { Colors } from "@/constants/Colors"; 8 | import { 9 | useGetUsersQuery, 10 | useUpdateUserMutation, 11 | } from "@/providers/redux/user/rtkSlice"; 12 | import type { User } from "@/types"; 13 | import { ThemedText } from "@/components/ThemedText"; 14 | import { ThemedView } from "@/components/ThemedView"; 15 | 16 | const UserItem = memo( 17 | ({ 18 | user, 19 | updateUserById, 20 | }: { 21 | user: User; 22 | updateUserById: (user: User) => void; 23 | }) => ( 24 | updateUserById({ ...user })} 28 | > 29 | {user.name} 30 | 31 | ) 32 | ); 33 | 34 | const Rtk = () => { 35 | const { 36 | data: users, 37 | isLoading: usersLoading, 38 | isSuccess, 39 | isError, 40 | error, 41 | refetch, 42 | } = useGetUsersQuery(); 43 | 44 | useFocusEffect( 45 | useCallback(() => { 46 | refetch(); 47 | }, []) 48 | ); 49 | 50 | const [updateUser, { isLoading }] = useUpdateUserMutation(); 51 | 52 | const updateUserClick = useCallback(async (user: User) => { 53 | try { 54 | await updateUser({ 55 | id: user.id, 56 | name: user.name + new Date().getSeconds(), 57 | }).unwrap(); 58 | } catch (error) { 59 | console.error("Failed to save the user", error); 60 | } 61 | }, []); 62 | 63 | return ( 64 | 65 | ( 72 | 77 | ), 78 | }} 79 | /> 80 | User List 81 | {users && users.length > 0 ? ( 82 | users.map((user) => ( 83 | 88 | )) 89 | ) : usersLoading ? ( 90 | 91 | ) : ( 92 | No User Found 93 | )} 94 | 95 | RTK Query with createEntityAdapter 96 | 97 | 98 | ); 99 | }; 100 | 101 | export default Rtk; 102 | 103 | const styles = StyleSheet.create({ 104 | container: { 105 | height: "100%", 106 | backgroundColor: "white", 107 | alignItems: "center", 108 | }, 109 | title: { 110 | marginVertical: 17, 111 | }, 112 | userList: { 113 | marginVertical: 7, 114 | borderColor: Colors.own, 115 | borderWidth: 1, 116 | width: "80%", 117 | paddingVertical: 7, 118 | paddingHorizontal: 17, 119 | }, 120 | btn: { 121 | width: "80%", 122 | marginTop: 27, 123 | backgroundColor: Colors.ownLight, 124 | borderRadius: 7, 125 | paddingVertical: 11, 126 | color: "white", 127 | textAlign: "center", 128 | fontWeight: "bold", 129 | }, 130 | }); 131 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import * as SecureStore from "expo-secure-store"; 2 | import { API_URL } from "@/config"; 3 | import Toast from "react-native-root-toast"; 4 | 5 | async function fetchWithRetry(url: string, options: {}, retries = 5) { 6 | try { 7 | const response = await fetch(url, options); 8 | 9 | if (!response.ok) { 10 | const res = await response.json(); 11 | console.log( 12 | "Error Response in redux-------- Url is ---", 13 | url, 14 | "--- status is ---", 15 | res 16 | ); 17 | 18 | if (response.status == 401 && res.error === "Error_AccessTokenExpired") { 19 | // Toast.show(res.message, { 20 | // duration: Toast.durations.LONG, 21 | // }); 22 | return { error: res.error }; 23 | } 24 | 25 | // Error_Attack - Must Log Out 26 | 27 | Toast.show(res.message, { duration: Toast.durations.LONG }); 28 | return { error: res.error }; 29 | } 30 | console.log("Url is ---", url, "--- status is ---", response.status); 31 | 32 | return response.json(); 33 | } catch (error) { 34 | if (retries > 0) { 35 | console.warn(`Retrying... (${retries} retries left) and url is ${url}`); 36 | 37 | await new Promise((resolve) => setTimeout(resolve, 1000)); 38 | return fetchWithRetry(url, options, retries - 1); 39 | } else 40 | Toast.show("Network request failed! Try again later.", { 41 | duration: Toast.durations.LONG, 42 | }); 43 | } 44 | } 45 | 46 | async function fetchRefreshToken() { 47 | // const token = await SecureStore.getItemAsync("token"); 48 | const refreshToken = await SecureStore.getItemAsync("refreshToken"); 49 | const randToken = await SecureStore.getItemAsync("randToken"); 50 | 51 | const url = API_URL + "refresh-token"; 52 | const method = "POST"; 53 | const headers = { 54 | accept: "application/json", 55 | "Content-Type": "application/json", 56 | Authorization: "Bearer " + "anythingisfine", 57 | }; 58 | const options = { 59 | method, 60 | headers, 61 | body: JSON.stringify({ refreshToken: refreshToken, randToken: randToken }), 62 | }; 63 | 64 | try { 65 | const response = await fetchWithRetry(url, options, 5); 66 | if (response.error) { 67 | return { refresh: false }; 68 | } else { 69 | await SecureStore.setItemAsync("token", response.token); 70 | await SecureStore.setItemAsync("refreshToken", response.refreshToken); 71 | await SecureStore.setItemAsync("randToken", response.randToken); 72 | } 73 | return { refresh: true, newToken: response.token }; 74 | } catch (error) { 75 | console.error("Failed to fetch Refresh Token: ", error || "Type error"); 76 | } 77 | } 78 | 79 | export const fetchApi = async (endpoint = "", method = "GET", data = {}) => { 80 | const token = await SecureStore.getItemAsync("token"); 81 | 82 | const url = API_URL + endpoint; 83 | const headers = { 84 | accept: "application/json", 85 | "Content-Type": "application/json", 86 | Authorization: "Bearer " + token, 87 | }; 88 | const options = 89 | Object.keys(data).length === 0 90 | ? { method, headers } 91 | : { method, headers, body: JSON.stringify(data) }; 92 | 93 | try { 94 | const response = await fetchWithRetry(url, options, 5); 95 | if (response.error && response.error === "Error_AccessTokenExpired") { 96 | // Call Refresh Token API 97 | // IF successful, call again fetchWithRetry() 98 | // console.log("Fetching refresh token --------"); 99 | 100 | const res = await fetchRefreshToken(); 101 | if (res?.refresh) { 102 | const newToken = res.newToken; 103 | const newhHeaders = { 104 | accept: "application/json", 105 | "Content-Type": "application/json", 106 | Authorization: "Bearer " + newToken, 107 | }; 108 | const newOptions = 109 | Object.keys(data).length === 0 110 | ? { method, headers: newhHeaders } 111 | : { method, headers: newhHeaders, body: JSON.stringify(data) }; 112 | 113 | const resAgain = await fetchWithRetry(url, newOptions, 5); 114 | // console.log("Finally success --------", resAgain); 115 | 116 | return resAgain; 117 | } else { 118 | return { error: "Error_Attack" }; 119 | } 120 | } 121 | return response; 122 | } catch (error) { 123 | console.error("Failed to fetch APi: ", error || "Type error"); 124 | } 125 | }; 126 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/(home)/rtkEntity.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, memo } from "react"; 2 | import { StyleSheet, ActivityIndicator, Pressable } from "react-native"; 3 | import { Link, Stack } from "expo-router"; 4 | 5 | import { IconSymbol } from "@/components/ui/IconSymbol"; 6 | import { Colors } from "@/constants/Colors"; 7 | import { 8 | useGetProductsQuery, 9 | useUpdateProductMutation, 10 | } from "@/providers/redux/user/rtkEntitySlice"; 11 | import type { Product } from "@/types"; 12 | import { ThemedText } from "@/components/ThemedText"; 13 | import { ThemedView } from "@/components/ThemedView"; 14 | 15 | const ProductItem = memo( 16 | ({ 17 | product, 18 | updateProductById, 19 | }: { 20 | product: Product; 21 | updateProductById: (product: Product) => void; 22 | }) => ( 23 | updateProductById({ ...product })} 27 | > 28 | {product.name} 29 | 30 | ) 31 | ); 32 | 33 | const RtkEntity = () => { 34 | const { 35 | data: products, 36 | isLoading: productsLoading, 37 | isSuccess, 38 | isError, 39 | error, 40 | refetch, 41 | } = useGetProductsQuery("getProducts"); 42 | 43 | let content; 44 | if (productsLoading) { 45 | content = ; 46 | } else if (isSuccess) { 47 | // console.log("Products-----", products); 48 | if (Object.keys(products.entities).length === 0) { 49 | return ( 50 | 53 | Data request has failed! 54 | 55 | Try again 56 | 57 | 58 | ); 59 | } 60 | 61 | const { ids, entities } = products; // ids : ["uuid1", "uuid2",...], entities: {"uuid1": {id: "uuid1", "name": "David22"}, ...} 62 | content = ids.map((productId: string) => ( 63 | updateProductClick(entities[productId])} 67 | /> 68 | )); 69 | } else if (isError) { 70 | content = An error occurs.; 71 | } 72 | 73 | const [updateProduct, { isLoading }] = useUpdateProductMutation(); 74 | 75 | const updateProductClick = useCallback(async (product: Product) => { 76 | try { 77 | await updateProduct({ 78 | id: product.id, 79 | name: product.name + new Date().getSeconds(), 80 | }).unwrap(); 81 | } catch (error) { 82 | console.error("Failed to save the user", error); 83 | } 84 | }, []); 85 | 86 | return ( 87 | 88 | ( 95 | 100 | ), 101 | }} 102 | /> 103 | 104 | RTK Query with createEntityAdapter 105 | 106 | {content} 107 | 108 | ); 109 | }; 110 | 111 | export default RtkEntity; 112 | 113 | const styles = StyleSheet.create({ 114 | container: { 115 | height: "100%", 116 | backgroundColor: "white", 117 | alignItems: "center", 118 | }, 119 | title: { 120 | marginVertical: 17, 121 | }, 122 | userList: { 123 | marginVertical: 7, 124 | borderColor: Colors.own, 125 | borderWidth: 1, 126 | width: "80%", 127 | paddingVertical: 7, 128 | paddingHorizontal: 17, 129 | }, 130 | btn: { 131 | width: "80%", 132 | marginTop: 27, 133 | backgroundColor: Colors.ownLight, 134 | borderRadius: 7, 135 | paddingVertical: 11, 136 | color: "white", 137 | textAlign: "center", 138 | fontWeight: "bold", 139 | }, 140 | btnError: { 141 | marginTop: 10, 142 | paddingVertical: 5, 143 | paddingHorizontal: 10, 144 | borderColor: "black", 145 | borderWidth: 0.5, 146 | borderRadius: 5, 147 | }, 148 | }); 149 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/(home)/normalize.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback } from "react"; 2 | import { StyleSheet, ActivityIndicator, Pressable } from "react-native"; 3 | import { Link, Stack } from "expo-router"; 4 | import Toast from "react-native-root-toast"; 5 | import { useFocusEffect } from "@react-navigation/native"; 6 | 7 | import { IconSymbol } from "@/components/ui/IconSymbol"; 8 | import { Colors } from "@/constants/Colors"; 9 | import { useSession } from "@/providers/ctx"; 10 | import { useAppSelector, useAppDispatch } from "@/hooks/useRedux"; 11 | import { 12 | getUsersEntity, 13 | updateUserEntity, 14 | selectAllUsers, 15 | error, 16 | loading, 17 | } from "@/providers/redux/user/entitySlice"; 18 | import type { User } from "@/types"; 19 | import { ThemedText } from "@/components/ThemedText"; 20 | import { ThemedView } from "@/components/ThemedView"; 21 | 22 | const UserItem = memo( 23 | ({ 24 | user, 25 | updateUserById, 26 | }: { 27 | user: User; 28 | updateUserById: (user: User) => void; 29 | }) => ( 30 | updateUserById({ ...user })} 34 | > 35 | {user.name} 36 | 37 | ) 38 | ); 39 | 40 | const Normalize = () => { 41 | const { signOut } = useSession(); 42 | 43 | useFocusEffect( 44 | useCallback(() => { 45 | getAllUsers(); 46 | }, []) 47 | ); 48 | 49 | const dispatch = useAppDispatch(); 50 | const users = useAppSelector(selectAllUsers); 51 | const isError = useAppSelector(error); 52 | const isLoading = useAppSelector(loading); 53 | 54 | const getAllUsers = useCallback(async () => { 55 | try { 56 | await dispatch(getUsersEntity()).unwrap(); 57 | } catch (error: any) { 58 | if (error === "Error_Attack") { 59 | // Error_Attack - Must Log Out 60 | Toast.show("Long time no see. Please Login again.", { 61 | duration: Toast.durations.LONG, 62 | }); 63 | signOut(); 64 | } else Toast.show(error, { duration: Toast.durations.LONG }); 65 | } 66 | }, []); 67 | 68 | const updateUserById = useCallback(async ({ id, name }: User) => { 69 | try { 70 | const user = { id, name: name + new Date().getSeconds() }; 71 | await dispatch(updateUserEntity(user)).unwrap(); 72 | } catch (error: any) { 73 | if (error === "Error_Attack") { 74 | // Error_Attack - Must Log Out 75 | Toast.show("Long time no see. Please Login again.", { 76 | duration: Toast.durations.LONG, 77 | }); 78 | signOut(); 79 | } else Toast.show(error, { duration: Toast.durations.LONG }); 80 | } 81 | }, []); 82 | 83 | return ( 84 | 85 | ( 92 | 97 | ), 98 | }} 99 | /> 100 | 101 | Normalized by createEntityAdapter 102 | 103 | {users && users.length > 0 ? ( 104 | users.map((user) => ( 105 | 106 | )) 107 | ) : isLoading ? ( 108 | 109 | ) : ( 110 | No User Found 111 | )} 112 | 113 | Press here to learn RTK Query 114 | 115 | 116 | ); 117 | }; 118 | 119 | export default Normalize; 120 | 121 | const styles = StyleSheet.create({ 122 | container: { 123 | height: "100%", 124 | backgroundColor: "white", 125 | alignItems: "center", 126 | }, 127 | title: { 128 | marginVertical: 17, 129 | }, 130 | userList: { 131 | marginVertical: 7, 132 | borderColor: Colors.own, 133 | borderWidth: 1, 134 | width: "80%", 135 | paddingVertical: 7, 136 | paddingHorizontal: 17, 137 | }, 138 | btn: { 139 | width: "80%", 140 | marginTop: 27, 141 | backgroundColor: Colors.ownLight, 142 | borderRadius: 7, 143 | paddingVertical: 11, 144 | color: "white", 145 | textAlign: "center", 146 | fontWeight: "bold", 147 | }, 148 | }); 149 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/explore.tsx: -------------------------------------------------------------------------------- 1 | import { StyleSheet, Image, Platform } from 'react-native'; 2 | 3 | import { Collapsible } from '@/components/Collapsible'; 4 | import { ExternalLink } from '@/components/ExternalLink'; 5 | import ParallaxScrollView from '@/components/ParallaxScrollView'; 6 | import { ThemedText } from '@/components/ThemedText'; 7 | import { ThemedView } from '@/components/ThemedView'; 8 | import { IconSymbol } from '@/components/ui/IconSymbol'; 9 | 10 | export default function TabTwoScreen() { 11 | return ( 12 | 21 | }> 22 | 23 | Explore 24 | 25 | This app includes example code to help you get started. 26 | 27 | 28 | This app has two screens:{' '} 29 | app/(tabs)/index.tsx and{' '} 30 | app/(tabs)/explore.tsx 31 | 32 | 33 | The layout file in app/(tabs)/_layout.tsx{' '} 34 | sets up the tab navigator. 35 | 36 | 37 | Learn more 38 | 39 | 40 | 41 | 42 | You can open this project on Android, iOS, and the web. To open the web version, press{' '} 43 | w in the terminal running this project. 44 | 45 | 46 | 47 | 48 | For static images, you can use the @2x and{' '} 49 | @3x suffixes to provide files for 50 | different screen densities 51 | 52 | 53 | 54 | Learn more 55 | 56 | 57 | 58 | 59 | Open app/_layout.tsx to see how to load{' '} 60 | 61 | custom fonts such as this one. 62 | 63 | 64 | 65 | Learn more 66 | 67 | 68 | 69 | 70 | This template has light and dark mode support. The{' '} 71 | useColorScheme() hook lets you inspect 72 | what the user's current color scheme is, and so you can adjust UI colors accordingly. 73 | 74 | 75 | Learn more 76 | 77 | 78 | 79 | 80 | This template includes an example of an animated component. The{' '} 81 | components/HelloWave.tsx component uses 82 | the powerful react-native-reanimated{' '} 83 | library to create a waving hand animation. 84 | 85 | {Platform.select({ 86 | ios: ( 87 | 88 | The components/ParallaxScrollView.tsx{' '} 89 | component provides a parallax effect for the header image. 90 | 91 | ), 92 | })} 93 | 94 | 95 | ); 96 | } 97 | 98 | const styles = StyleSheet.create({ 99 | headerImage: { 100 | color: '#808080', 101 | bottom: -90, 102 | left: -35, 103 | position: 'absolute', 104 | }, 105 | titleContainer: { 106 | flexDirection: 'row', 107 | gap: 8, 108 | }, 109 | }); 110 | -------------------------------------------------------------------------------- /app/(app)/(tabs)/(home)/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useCallback, memo } from "react"; 2 | import { 3 | Image, 4 | StyleSheet, 5 | Platform, 6 | Pressable, 7 | ActivityIndicator, 8 | } from "react-native"; 9 | import Toast from "react-native-root-toast"; 10 | import { Link, useNavigation } from "expo-router"; 11 | import { useFocusEffect } from "@react-navigation/native"; 12 | import ParallaxScrollView from "@/components/ParallaxScrollView"; 13 | import { ThemedText } from "@/components/ThemedText"; 14 | import { ThemedView } from "@/components/ThemedView"; 15 | import { useSession } from "@/providers/ctx"; 16 | import { useAppSelector, useAppDispatch } from "@/hooks/useRedux"; 17 | import { getUsers, updateUser } from "@/providers/redux/user/userSlice"; 18 | import type { User } from "@/types"; 19 | import { Colors } from "@/constants/Colors"; 20 | 21 | const UserItem = memo( 22 | ({ 23 | user, 24 | updateUserById, 25 | }: { 26 | user: User; 27 | updateUserById: (user: User) => void; 28 | }) => ( 29 | updateUserById({ ...user })} 33 | > 34 | {user.name} 35 | 36 | ) 37 | ); 38 | 39 | export default function HomeScreen() { 40 | const navigation = useNavigation(); 41 | const { signOut } = useSession(); 42 | 43 | useEffect(() => { 44 | navigation.setOptions({ headerShown: false }); 45 | }, [navigation]); 46 | 47 | useFocusEffect( 48 | useCallback(() => { 49 | getAllUsers(); 50 | }, []) 51 | ); 52 | 53 | const dispatch = useAppDispatch(); 54 | const { users, isFetching } = useAppSelector((state) => state.users); 55 | 56 | const getAllUsers = useCallback(async () => { 57 | try { 58 | await dispatch(getUsers()).unwrap(); 59 | } catch (error: any) { 60 | if (error === "Error_Attack") { 61 | // Error_Attack - Must Log Out 62 | Toast.show("Long time no see. Please Login again.", { 63 | duration: Toast.durations.LONG, 64 | }); 65 | signOut(); 66 | } else Toast.show(error, { duration: Toast.durations.LONG }); 67 | } 68 | }, []); 69 | 70 | const updateUserById = useCallback(async ({ id, name }: User) => { 71 | try { 72 | const user = { id, name: name + new Date().getSeconds() }; 73 | await dispatch(updateUser(user)).unwrap(); 74 | } catch (error: any) { 75 | if (error === "Error_Attack") { 76 | // Error_Attack - Must Log Out 77 | Toast.show("Long time no see. Please Login again.", { 78 | duration: Toast.durations.LONG, 79 | }); 80 | signOut(); 81 | } else Toast.show(error, { duration: Toast.durations.LONG }); 82 | } 83 | }, []); 84 | 85 | return ( 86 | 93 | } 94 | > 95 | 96 | User List 97 | 98 | Fetched by createAsyncThunk & createSlice 99 | 100 | 101 | Press here to learn createEntityAdapter 102 | 103 | {users && users.length > 0 ? ( 104 | users.map((user) => ( 105 | 110 | )) 111 | ) : isFetching ? ( 112 | 113 | ) : ( 114 | No User Found 115 | )} 116 | 117 | Log Out 118 | 119 | 120 | 121 | ); 122 | } 123 | 124 | const styles = StyleSheet.create({ 125 | titleContainer: { 126 | // flexDirection: "row", 127 | alignItems: "center", 128 | gap: 11, 129 | }, 130 | reactLogo: { 131 | height: 178, 132 | width: 290, 133 | bottom: 0, 134 | left: 0, 135 | position: "absolute", 136 | }, 137 | btn: { 138 | width: "90%", 139 | backgroundColor: Colors.ownLight, 140 | borderRadius: 7, 141 | paddingVertical: 11, 142 | color: "white", 143 | textAlign: "center", 144 | fontWeight: "bold", 145 | }, 146 | userList: { 147 | marginVertical: 3, 148 | borderColor: Colors.own, 149 | borderWidth: 1, 150 | width: "80%", 151 | paddingVertical: 7, 152 | paddingHorizontal: 17, 153 | }, 154 | logout: { 155 | borderColor: Colors.ownLight, 156 | borderWidth: 1, 157 | paddingVertical: 7, 158 | paddingHorizontal: 17, 159 | borderRadius: 7, 160 | marginTop: 7, 161 | }, 162 | }); 163 | -------------------------------------------------------------------------------- /app/login.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StyleSheet, 3 | Text, 4 | View, 5 | TextInput, 6 | ScrollView, 7 | Pressable, 8 | } from "react-native"; 9 | import { SafeAreaView } from "react-native-safe-area-context"; 10 | import { useForm, Controller } from "react-hook-form"; 11 | import { router } from "expo-router"; 12 | import { Image } from "expo-image"; 13 | 14 | import { useSession } from "@/providers/ctx"; 15 | 16 | const blurhash = 17 | "|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WVj[ayayj[fQoff7azayj[ayj[j[ayofayayayj[fQj[ayayj[ayfjj[j[ayjuayj["; 18 | 19 | export default function Login() { 20 | const { signIn } = useSession(); 21 | const { 22 | control, 23 | handleSubmit, 24 | formState: { errors }, 25 | } = useForm({ 26 | defaultValues: { 27 | phone: "", 28 | password: "", 29 | }, 30 | }); 31 | 32 | const onSubmit = async (formState: any) => { 33 | // console.log(formState); 34 | await signIn(formState); 35 | // Navigate after signing in. You may want to tweak this to ensure sign-in is 36 | // successful before navigating. 37 | router.replace("/"); 38 | }; 39 | 40 | return ( 41 | 42 | 43 | 44 | 51 | Fashion 52 | 53 | Sign in {"\n"}to your Account 54 | 55 | Enter your name & password to log in 56 | 57 | Phone Number 58 | ( 65 | 74 | )} 75 | name="phone" 76 | /> 77 | {errors.phone && ( 78 | This is required. 79 | )} 80 | 81 | Password ( Must be 8 digits. ) 82 | 83 | ( 90 | 100 | )} 101 | name="password" 102 | /> 103 | {errors.password && ( 104 | Password is invalid. 105 | )} 106 | Forgot Password? 107 | 111 | Log In 112 | 113 | 114 | 115 | ); 116 | } 117 | 118 | const styles = StyleSheet.create({ 119 | container: { 120 | marginHorizontal: 20, 121 | }, 122 | row: { 123 | flexDirection: "row", 124 | justifyContent: "flex-end", 125 | gap: 5, 126 | marginTop: 12, 127 | }, 128 | logo: { width: 20, height: 20 }, 129 | logoText: { 130 | fontSize: 18, 131 | fontWeight: "bold", 132 | }, 133 | title: { 134 | fontSize: 36, 135 | fontWeight: "bold", 136 | lineHeight: 46, 137 | }, 138 | subTitle: { 139 | marginTop: 12, 140 | fontWeight: "700", 141 | color: "gray", 142 | }, 143 | label: { 144 | marginTop: 27, 145 | marginBottom: 7, 146 | }, 147 | input: { 148 | backgroundColor: "white", 149 | color: "black", 150 | borderWidth: 0.5, 151 | borderColor: "#8c8c8c55", 152 | fontSize: 15, 153 | paddingVertical: 17, 154 | paddingHorizontal: 15, 155 | borderRadius: 9, 156 | }, 157 | forgotText: { 158 | marginTop: 15, 159 | marginBottom: 25, 160 | fontWeight: "700", 161 | color: "#2772DA", 162 | textAlign: "right", 163 | }, 164 | btnText: { 165 | color: "white", 166 | fontSize: 15, 167 | fontWeight: "700", 168 | textAlign: "center", 169 | }, 170 | errorText: { 171 | paddingTop: 8, 172 | fontWeight: "bold", 173 | color: "#EF4444", 174 | }, 175 | }); 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native Expo Starter Kits 2 | 3 | Do you want single code base for both android and ios application? Here you are. 4 | If you find it useful, give me a **GitHub star**, please. 5 | 6 | In this template, you will see ... 7 | 8 | - Expo framework ( New Architecture ) 9 | - json-server ( For testing API ) 10 | - Redux toolkit 11 | - createSlice & createAsyncThunk 12 | - createSlice & createAsyncThunk & createEntityAdapter 13 | - RTK query 14 | - createApi 15 | - createApi & createEntityAdapter 16 | - React query ( I'll add soon. ) 17 | - FlashList ( @shopify ) ( I'll add soon. ) 18 | - React context 19 | - expo secure store 20 | - react hook form 21 | - Authentication ( Access token & Refresh token ) 22 | - Custom Font 23 | - IconSymbol ( SF Symbols ) 24 | - Dark mode 25 | - Rest api client 26 | - graphql client ( Apollo client ) 27 | - Retry mechanism for fetching api etc. 28 | 29 | ## Find more other Starter kits of mine ? 30 | 31 | `Nest JS for REST Api` 32 | 33 | [Nest JS + Prisma ORM - REST api](https://github.com/Bonekyaw/nest-prisma-sql-rest) 34 | 35 | `Nest JS for Graphql Api` 36 | 37 | [Nest JS + Prisma ORM - Graphql api](https://github.com/Bonekyaw/nest-prisma-graphql) 38 | 39 | `Node Express JS For REST Api` 40 | 41 | [Express + Prisma ORM + mongodb - rest api](https://github.com/Bonekyaw/node-express-prisma-mongodb) 42 | [Express + Prisma ORM + SQL - rest api](https://github.com/Bonekyaw/node-express-prisma-rest) 43 | [Express + mongodb - rest api](https://github.com/Bonekyaw/node-express-mongodb-rest) 44 | [Express + mongoose ODM - rest api](https://github.com/Bonekyaw/node-express-nosql-rest) 45 | [Express + sequelize ORM - rest api](https://github.com/Bonekyaw/node-express-sql-rest) 46 | 47 | `Node Express JS For Graphql Api` 48 | 49 | [Apollo server + Prisma ORM + SDL modulerized - graphql api](https://github.com/Bonekyaw/apollo-graphql-prisma) 50 | [Express + Prisma ORM + graphql js SDL modulerized - graphql api](https://github.com/Bonekyaw/node-express-graphql-prisma) 51 | [Express + Apollo server + mongoose - graphql api](https://github.com/Bonekyaw/node-express-apollo-nosql) 52 | [Express + graphql js + mongoose - graphql api](https://github.com/Bonekyaw/node-express-nosql-graphql) 53 | [Express + graphql js + sequelize ORM - graphql api](https://github.com/Bonekyaw/node-express-sql-graphql) 54 | 55 | `Mobile Application Development` 56 | 57 | [React Native Expo](https://github.com/Bonekyaw/react-native-expo) - Now you are here 58 | 59 | ( Now I'm on the way of these two Starter Kits. Stay tuned, please. ) 60 | 61 | ### Requirements 62 | 63 | - [Environment Setup](https://reactnative.dev/docs/set-up-your-environment) 64 | 65 | #### Is setting up my environment required? 66 | 67 | > Setting up your environment is not required if you're using a Framework. With Expo Framework, you don't need to setup Android Studio or XCode as a Framework will take care of building the native app for you. 68 | > 69 | > If you have constraints that prevent you from using a Framework, or you'd like to write your own Framework, then setting up your local environment is a requirement. After your environment is set up, learn how to get started without a framework. 70 | 71 | But my recommendation is that you should set up if possible. Then you can build your app using not only eas-build but also xcode or android studio. 72 | 73 | See [here](https://docs.expo.dev/tutorial/eas/introduction/) to learn how to deploy. 74 | 75 | #### How to run Starter Kits 76 | 77 | First of all, you should clone it from my github. 78 | 79 | ```bash 80 | git clone https://github.com/Bonekyaw/react-native-expo.git 81 | cd react-native-expo 82 | rm -rf .git 83 | npm install 84 | npm start 85 | ``` 86 | 87 | Now, you can start your project by running: 88 | 89 | Open new terminal in vscode and Run json-server for API 90 | 91 | ```bash 92 | npx json-server json-server-to-test-api/db.json --port 8080 93 | ``` 94 | 95 | And then you can run metro bundler for expo app 96 | 97 | ```bash 98 | npx expo start 99 | ``` 100 | 101 | You can upgrade latest version if expo is outdated. [Read more](https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/) 102 | 103 | ```bash 104 | npm install expo@latest 105 | 106 | npx expo install --fix 107 | ``` 108 | 109 | > To view your app on a mobile device, we recommend starting with Expo Go. As your application grows in complexity and you need more control, you can create a development build. 110 | > 111 | > Open the project in a web browser by pressing w in the Terminal UI. Press a for Android (Android Studio is required), or i for iOS (macOS with Xcode is required). 112 | 113 | Or you can create a new expo app by running: 114 | 115 | ```bash 116 | npx create-expo-app@latest 117 | ``` 118 | 119 | And then you can copy files or codes in my Starter Kits. Don't forget to install packages manually. 120 | 121 | If you are a burmese developer, you should watch my explanation video on YouTube [Here](https://youtu.be/5k4ixhrcBNI?si=8d0R7EjapLp10Nfm). I promise new features will come in the future if I have much time. 122 | 123 | If you have something hard to solve, 124 | DM 125 | 126 | 127 | 128 | 129 | 130 | ### Some features in some files 131 | 132 | ### json-server 133 | 134 | `json-server-to-test-api/db.json` 135 | 136 | ```bash 137 | npx json-server json-server-to-test-api/db.json --port 8080 138 | ``` 139 | 140 | #### Authentication process 141 | 142 | `login.tsx` 143 | `providers/ctx.tsx` 144 | `(hook)/useStorageState.ts` 145 | `api/auth.ts` 146 | 147 | #### routes in App 148 | 149 | `/login` 150 | `/` 151 | `/normalize` 152 | `/rtk` 153 | `/rtkEntity` 154 | `/explore` 155 | 156 | #### redux toolkit 157 | 158 | `providers/redux/store.ts` 159 | `providers/redux/user/userSlice.ts` 160 | `providers/redux/user/entitySlice.ts` 161 | `hooks/useRedux.ts` 162 | `/api/index.ts` 163 | 164 | #### RTK query 165 | 166 | `providers/redux/query/apiSlice.ts` 167 | `providers/redux/query/baseQueryWithRefresh.ts` 168 | `providers/redux/user/rtkSlice.ts` 169 | `providers/redux/user/rtkEntitySlice.ts` 170 | `providers/redux/store.ts` 171 | 172 | #### React query 173 | 174 | `I will add again. Please wait.` 175 | 176 | #### FlashList ( @shopify ) 177 | 178 | `I will add again. Please wait.` 179 | 180 | #### React context 181 | 182 | `providers/ctx.tsx` 183 | 184 | #### expo secure store 185 | 186 | `(hook)/useStorageState.ts` 187 | 188 | #### react hook form 189 | 190 | `login.tsx` 191 | 192 | #### Custom Font & Dark mode 193 | 194 | `components/ThemedText.tsx` 195 | `components/ThemedView.tsx` 196 | 197 | #### graphql client ( Apollo client ) 198 | 199 | `providers/graphql/ApolloClient.ts` 200 | `providers/graphql/sample/query.tsx` 201 | `providers/graphql/sample/mutation.tsx` 202 | 203 | If you need a graphql api server to test, I provided the whole Starter Kits. See above `My Kits For Graphql Api`. 204 | --------------------------------------------------------------------------------