├── 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 |
--------------------------------------------------------------------------------