create-expo-boilerplate 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +1 -1
- package/dist/steps/readme.js +1 -1
- package/dist/template.js +4 -3
- package/overlays/react-navigation/src/shared/hooks/useScreenLoader.ts +18 -0
- package/overlays/styling/stylesheet/common/src/shared/components/FullScreenLoader.tsx +39 -25
- package/overlays/styling/stylesheet/common/src/shared/components/ScreenWrapper.tsx +28 -19
- package/overlays/styling/tailwind/common/src/shared/components/FullScreenLoader.tsx +34 -14
- package/overlays/styling/tailwind/common/src/shared/components/ScreenWrapper.tsx +23 -19
- package/package.json +22 -4
- package/template/.easignore +1 -0
- package/template/.env.example +0 -3
- package/template/.skills/api-hook-creation.md +1 -1
- package/template/.skills/ui-creation.md +3 -2
- package/template/__mocks__/@env.ts +0 -1
- package/template/cliff.toml +1 -1
- package/template/env.ts +0 -2
- package/template/eslint.config.js +1 -0
- package/template/jest.config.js +3 -3
- package/template/src/providers/AppProviders.tsx +2 -3
- package/template/src/shared/components/AppOverlay.tsx +32 -0
- package/template/src/shared/components/FullScreenLoader.tsx +39 -25
- package/template/src/shared/components/ScreenWrapper.tsx +28 -19
- package/template/src/shared/constants/index.ts +0 -2
- package/template/src/shared/hooks/index.ts +1 -0
- package/template/src/shared/hooks/useScreenLoader.ts +18 -0
- package/template/src/shared/utils/__tests__/appLoader.test.ts +44 -0
- package/template/src/shared/utils/appLoader.ts +33 -0
- package/template/tsconfig.json +1 -0
- package/template/src/shared/constants/ApiUrls.ts +0 -4
- package/template/src/shared/constants/deviceInfo.ts +0 -11
- /package/{template → overlays/redux/common}/src/shared/constants/status.ts +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# create-expo-boilerplate
|
|
2
2
|
|
|
3
|
-
Scaffold a new Expo app from [
|
|
3
|
+
Scaffold a new Expo app from [react-native-expo-boilerplate](https://github.com/EndLess728/react-native-expo-boilerplate) ([docs](https://endless728.github.io/react-native-expo-boilerplate/)) — no cloning, no deleting example code by hand.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npx create-expo-boilerplate my-app
|
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@ const oneOf = (choices) => (value) => {
|
|
|
19
19
|
};
|
|
20
20
|
const program = new Command()
|
|
21
21
|
.name('create-expo-boilerplate')
|
|
22
|
-
.description('Scaffold an Expo app from
|
|
22
|
+
.description('Scaffold an Expo app from react-native-expo-boilerplate.')
|
|
23
23
|
.version(version)
|
|
24
24
|
.argument('[directory]', 'where to create the project')
|
|
25
25
|
.option('--state <library>', 'zustand | redux', oneOf(CHOICES.state))
|
package/dist/steps/readme.js
CHANGED
|
@@ -36,7 +36,7 @@ export function renderReadme(o) {
|
|
|
36
36
|
.join(' + ');
|
|
37
37
|
const lines = [];
|
|
38
38
|
const push = (...l) => lines.push(...l);
|
|
39
|
-
push(`# ${o.displayName}`, '', 'Expo app generated with [create-expo-boilerplate](https://github.
|
|
39
|
+
push(`# ${o.displayName}`, '', 'Expo app generated with [create-expo-boilerplate](https://endless728.github.io/react-native-expo-boilerplate/).', '', '## Stack', '', '| Category | Choice |', '|---|---|', `| **Language** | ${ts ? 'TypeScript (strict)' : 'JavaScript'} |`, `| **Navigation** | ${navLabel} |`, `| **State** | ${stateLabel} |`, '| **Data fetching** | TanStack Query v5 + Axios + react-query-kit |', '| **Forms** | React Hook Form + Zod |', `| **Styling** | ${STYLING_README[o.styling]} |`, '| **Storage** | MMKV, AES-encrypted, key kept in the iOS Keychain / Android Keystore |', '| **i18n** | i18next + react-i18next (en, es, ar with RTL) |', '| **Testing** | jest-expo + React Native Testing Library |', `| **Tooling** | ${tooling} |`, '', '## Getting started', '', '```bash', `${PM[o.packageManager].install} # also seeds .env.development / .staging / .production`, '$EDITOR .env.development # fill in your API URLs', `${run} android:development # prebuild + run on Android`, `${run} ios:development # prebuild + run on iOS`, '```', '', '`android/` and `ios/` are generated by `expo prebuild` and are not committed.', '', '## Project structure', '', '```', '├── src/');
|
|
40
40
|
if (isRouter) {
|
|
41
41
|
push(`│ ├── app/ # Expo Router routes — thin re-exports of feature screens`, `│ │ ├── _layout.${xx} # bootstrap + providers + <Stack.Protected> auth gating`, `│ │ ├── (auth)/login.${xx}`, `│ │ └── (tabs)/ # index → Home, settings → Settings`);
|
|
42
42
|
}
|
package/dist/template.js
CHANGED
|
@@ -24,6 +24,7 @@ const EXCLUDED = [
|
|
|
24
24
|
/^README\.md$/, // every generated project gets its own README
|
|
25
25
|
/^LICENSE$/, // the boilerplate's license; a generated app is its owner's to license
|
|
26
26
|
/^docs\//, // guides for this repo's own stack and CI; generated apps get a README for theirs
|
|
27
|
+
/^website\//, // the documentation site (GitHub Pages), not part of an app
|
|
27
28
|
/^\.env\.(development|staging|production)$/, // seeded from .env.example on install
|
|
28
29
|
/^\.env(\.local)?$/,
|
|
29
30
|
];
|
|
@@ -67,7 +68,7 @@ export async function copyBaseTemplate(targetDir) {
|
|
|
67
68
|
}
|
|
68
69
|
/** The boilerplate repo's configs exclude `cli/`; generated apps have no such folder. */
|
|
69
70
|
async function stripRepoOnlyConfig(files) {
|
|
70
|
-
await files.replace('tsconfig.json', ',\n "cli"\n', '\n');
|
|
71
|
-
await files.replace('jest.config.js', /\n {2}\/\/ `cli\/` is the create-expo-boilerplate package[\s\S]*?modulePathIgnorePatterns: \['<rootDir>\/cli\/'\],/, '');
|
|
72
|
-
await files.replace('eslint.config.js', " '**/cli/*',\n", '', { optional: true });
|
|
71
|
+
await files.replace('tsconfig.json', ',\n "website",\n "cli"\n', '\n');
|
|
72
|
+
await files.replace('jest.config.js', /\n {2}\/\/ `cli\/` is the create-expo-boilerplate package[\s\S]*?modulePathIgnorePatterns: \['<rootDir>\/cli\/', '<rootDir>\/website\/'\],/, '');
|
|
73
|
+
await files.replace('eslint.config.js', " '**/cli/*',\n '**/website/*',\n", '', { optional: true });
|
|
73
74
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useIsFocused } from '@react-navigation/native';
|
|
3
|
+
|
|
4
|
+
import { requestAppLoader } from '@/shared/utils/appLoader';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shows the app-wide loader while `active` is true and this screen is focused,
|
|
8
|
+
* so a tab or screen loading in the background never blocks the one in view.
|
|
9
|
+
*/
|
|
10
|
+
export function useScreenLoader(active: boolean): void {
|
|
11
|
+
const isFocused = useIsFocused();
|
|
12
|
+
const shouldShow = active && isFocused;
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!shouldShow) return;
|
|
16
|
+
return requestAppLoader();
|
|
17
|
+
}, [shouldShow]);
|
|
18
|
+
}
|
|
@@ -1,50 +1,64 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { ActivityIndicator,
|
|
2
|
+
import { ActivityIndicator, Text, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { translate } from '@/localization/utils';
|
|
5
5
|
import { ms } from '@/shared/utils';
|
|
6
6
|
import { makeStyles, TextStyles, useAppTheme } from '@/theme';
|
|
7
7
|
|
|
8
|
-
interface
|
|
8
|
+
interface FullScreenLoaderProps {
|
|
9
9
|
visible: boolean;
|
|
10
10
|
size?: 'small' | 'large';
|
|
11
11
|
color?: string;
|
|
12
|
+
/** Uses a lighter dim and theme-colored text, so a modal still looks solid. */
|
|
13
|
+
inModal?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Loading overlay that fills its parent and blocks touches on what's beneath it.
|
|
18
|
+
*
|
|
19
|
+
* It is a plain view rather than a native `Modal`, so it can't get stuck over the
|
|
20
|
+
* app. Screens get it through `ScreenWrapper`'s `showLoader`. Inside a modal, pass
|
|
21
|
+
* `isModal` to `ScreenWrapper`, or render it here with `inModal` as the last child
|
|
22
|
+
* of the view to cover (give a view with rounded corners `overflow: 'hidden'`).
|
|
23
|
+
*/
|
|
24
|
+
const FullScreenLoader: React.FC<FullScreenLoaderProps> = ({
|
|
15
25
|
visible = false,
|
|
16
26
|
size = 'large',
|
|
17
27
|
color,
|
|
28
|
+
inModal = false,
|
|
18
29
|
}) => {
|
|
19
30
|
const theme = useAppTheme();
|
|
20
31
|
const styles = useStyles();
|
|
21
32
|
|
|
33
|
+
if (!visible) return null;
|
|
34
|
+
|
|
35
|
+
const label = translate('common.loading');
|
|
36
|
+
|
|
22
37
|
return (
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
<View
|
|
39
|
+
accessibilityViewIsModal
|
|
40
|
+
accessible
|
|
41
|
+
accessibilityLabel={label}
|
|
42
|
+
accessibilityRole="progressbar"
|
|
43
|
+
style={styles.overlay}
|
|
44
|
+
>
|
|
45
|
+
<View style={inModal ? styles.modalBackdrop : styles.screenBackdrop} />
|
|
46
|
+
<ActivityIndicator color={color || theme.colors.primary} size={size} />
|
|
47
|
+
<Text style={[TextStyles.label, inModal ? styles.modalText : styles.screenText]}>
|
|
48
|
+
{label}
|
|
49
|
+
</Text>
|
|
50
|
+
</View>
|
|
31
51
|
);
|
|
32
52
|
};
|
|
33
53
|
|
|
54
|
+
const fill = { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 } as const;
|
|
55
|
+
|
|
34
56
|
const useStyles = makeStyles((theme) => ({
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
activityIndicatorWrapper: {
|
|
42
|
-
padding: ms(20),
|
|
43
|
-
borderRadius: ms(10),
|
|
44
|
-
alignItems: 'center',
|
|
45
|
-
justifyContent: 'center',
|
|
46
|
-
},
|
|
47
|
-
loadingText: { color: theme.colors.white, marginTop: ms(10) },
|
|
57
|
+
overlay: { ...fill, justifyContent: 'center', alignItems: 'center' },
|
|
58
|
+
screenBackdrop: { ...fill, backgroundColor: theme.colors.opacity50 },
|
|
59
|
+
modalBackdrop: { ...fill, backgroundColor: 'rgba(0,0,0,0.35)' },
|
|
60
|
+
screenText: { color: theme.colors.white, marginTop: ms(10) },
|
|
61
|
+
modalText: { color: theme.colors.typography, marginTop: ms(10) },
|
|
48
62
|
}));
|
|
49
63
|
|
|
50
|
-
export default
|
|
64
|
+
export default FullScreenLoader;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
Image,
|
|
4
4
|
type ImageResizeMode,
|
|
5
5
|
type ImageSourcePropType,
|
|
6
6
|
type ImageStyle,
|
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
|
12
12
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
13
13
|
|
|
14
|
-
import
|
|
14
|
+
import FullScreenLoader from '@/shared/components/FullScreenLoader';
|
|
15
|
+
import { useScreenLoader } from '@/shared/hooks/useScreenLoader';
|
|
15
16
|
import { useAppTheme } from '@/theme';
|
|
16
17
|
|
|
17
18
|
interface ScreenWrapperProps {
|
|
@@ -19,6 +20,8 @@ interface ScreenWrapperProps {
|
|
|
19
20
|
/** Layout style (padding, justifyContent, alignItems, etc.). Routed to the content container in scrollable mode, or the outer View otherwise. */
|
|
20
21
|
style?: ViewStyle;
|
|
21
22
|
showLoader?: boolean;
|
|
23
|
+
/** Set on modal screens, and inside a React Native `<Modal>`: `showLoader` then covers only this content, not the whole app. */
|
|
24
|
+
isModal?: boolean;
|
|
22
25
|
/** Enable for any screen with text inputs. Scrolls only when needed to keep the focused input above the keyboard. */
|
|
23
26
|
scrollable?: boolean;
|
|
24
27
|
/** Optional full-screen background image. Renders behind all content. */
|
|
@@ -35,12 +38,16 @@ const ScreenWrapper: React.FC<ScreenWrapperProps> = ({
|
|
|
35
38
|
children,
|
|
36
39
|
style,
|
|
37
40
|
showLoader = false,
|
|
41
|
+
isModal = false,
|
|
38
42
|
scrollable = false,
|
|
39
43
|
backgroundImage,
|
|
40
44
|
backgroundImageStyle,
|
|
41
45
|
backgroundImageResizeMode = 'cover',
|
|
42
46
|
keyboardBottomOffset = 20,
|
|
43
47
|
}) => {
|
|
48
|
+
useScreenLoader(showLoader && !isModal);
|
|
49
|
+
const modalLoader = isModal ? <FullScreenLoader inModal visible={showLoader} /> : null;
|
|
50
|
+
|
|
44
51
|
const theme = useAppTheme();
|
|
45
52
|
const insets = useSafeAreaInsets();
|
|
46
53
|
const wrapperStyle = [styles.wrapper, { backgroundColor: theme.colors.background }];
|
|
@@ -60,24 +67,19 @@ const ScreenWrapper: React.FC<ScreenWrapperProps> = ({
|
|
|
60
67
|
<View style={[styles.viewContainer, bottomInset, style]}>{children}</View>
|
|
61
68
|
);
|
|
62
69
|
|
|
63
|
-
const body = backgroundImage ? (
|
|
64
|
-
<ImageBackground
|
|
65
|
-
imageStyle={backgroundImageStyle}
|
|
66
|
-
resizeMode={backgroundImageResizeMode}
|
|
67
|
-
source={backgroundImage}
|
|
68
|
-
style={wrapperStyle}
|
|
69
|
-
>
|
|
70
|
-
{content}
|
|
71
|
-
</ImageBackground>
|
|
72
|
-
) : (
|
|
73
|
-
<View style={wrapperStyle}>{content}</View>
|
|
74
|
-
);
|
|
75
|
-
|
|
76
70
|
return (
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
<View style={wrapperStyle}>
|
|
72
|
+
{backgroundImage ? (
|
|
73
|
+
<Image
|
|
74
|
+
accessibilityIgnoresInvertColors
|
|
75
|
+
resizeMode={backgroundImageResizeMode}
|
|
76
|
+
source={backgroundImage}
|
|
77
|
+
style={[styles.backgroundImage, backgroundImageStyle]}
|
|
78
|
+
/>
|
|
79
|
+
) : null}
|
|
80
|
+
{content}
|
|
81
|
+
{modalLoader}
|
|
82
|
+
</View>
|
|
81
83
|
);
|
|
82
84
|
};
|
|
83
85
|
|
|
@@ -87,6 +89,13 @@ const styles = StyleSheet.create({
|
|
|
87
89
|
wrapper: {
|
|
88
90
|
flex: 1,
|
|
89
91
|
},
|
|
92
|
+
// The explicit size overrides the file's own dimensions, which `Image` applies
|
|
93
|
+
// to a `require`d source and which would otherwise beat the absolute edges.
|
|
94
|
+
backgroundImage: {
|
|
95
|
+
position: 'absolute',
|
|
96
|
+
width: '100%',
|
|
97
|
+
height: '100%',
|
|
98
|
+
},
|
|
90
99
|
container: {
|
|
91
100
|
flex: 1,
|
|
92
101
|
},
|
|
@@ -1,34 +1,54 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { ActivityIndicator,
|
|
2
|
+
import { ActivityIndicator, Text, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { translate } from '@/localization/utils';
|
|
5
5
|
import { useAppTheme } from '@/theme';
|
|
6
6
|
|
|
7
|
-
interface
|
|
7
|
+
interface FullScreenLoaderProps {
|
|
8
8
|
visible: boolean;
|
|
9
9
|
size?: 'small' | 'large';
|
|
10
10
|
color?: string;
|
|
11
|
+
/** Uses a lighter dim and theme-colored text, so a modal still looks solid. */
|
|
12
|
+
inModal?: boolean;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Loading overlay that fills its parent and blocks touches on what's beneath it.
|
|
17
|
+
*
|
|
18
|
+
* It is a plain view rather than a native `Modal`, so it can't get stuck over the
|
|
19
|
+
* app. Screens get it through `ScreenWrapper`'s `showLoader`. Inside a modal, pass
|
|
20
|
+
* `isModal` to `ScreenWrapper`, or render it here with `inModal` as the last child
|
|
21
|
+
* of the view to cover (give a view with rounded corners `overflow-hidden`).
|
|
22
|
+
*/
|
|
23
|
+
const FullScreenLoader: React.FC<FullScreenLoaderProps> = ({
|
|
14
24
|
visible = false,
|
|
15
25
|
size = 'large',
|
|
16
26
|
color,
|
|
27
|
+
inModal = false,
|
|
17
28
|
}) => {
|
|
18
29
|
const theme = useAppTheme();
|
|
19
30
|
|
|
31
|
+
if (!visible) return null;
|
|
32
|
+
|
|
33
|
+
const label = translate('common.loading');
|
|
34
|
+
|
|
20
35
|
return (
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
<View
|
|
37
|
+
accessibilityViewIsModal
|
|
38
|
+
accessible
|
|
39
|
+
accessibilityLabel={label}
|
|
40
|
+
accessibilityRole="progressbar"
|
|
41
|
+
className="absolute inset-0 items-center justify-center"
|
|
42
|
+
>
|
|
43
|
+
<View className={`absolute inset-0 ${inModal ? 'bg-black/35' : 'bg-opacity50'}`} />
|
|
44
|
+
<ActivityIndicator color={color || theme.colors.primary} size={size} />
|
|
45
|
+
<Text
|
|
46
|
+
className={`text-label font-opensans-bold mt-2.5 ${inModal ? 'text-typography' : 'text-white'}`}
|
|
47
|
+
>
|
|
48
|
+
{label}
|
|
49
|
+
</Text>
|
|
50
|
+
</View>
|
|
31
51
|
);
|
|
32
52
|
};
|
|
33
53
|
|
|
34
|
-
export default
|
|
54
|
+
export default FullScreenLoader;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
Image,
|
|
4
4
|
type ImageResizeMode,
|
|
5
5
|
type ImageSourcePropType,
|
|
6
6
|
View,
|
|
@@ -8,13 +8,16 @@ import {
|
|
|
8
8
|
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
|
9
9
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
10
10
|
|
|
11
|
-
import
|
|
11
|
+
import FullScreenLoader from '@/shared/components/FullScreenLoader';
|
|
12
|
+
import { useScreenLoader } from '@/shared/hooks/useScreenLoader';
|
|
12
13
|
|
|
13
14
|
interface ScreenWrapperProps {
|
|
14
15
|
children: React.ReactNode;
|
|
15
16
|
/** Layout classes (padding, justify-*, items-*, …). Applied to the content in both scrollable and static mode. */
|
|
16
17
|
className?: string;
|
|
17
18
|
showLoader?: boolean;
|
|
19
|
+
/** Set on modal screens, and inside a React Native `<Modal>`: `showLoader` then covers only this content, not the whole app. */
|
|
20
|
+
isModal?: boolean;
|
|
18
21
|
/** Enable for any screen with text inputs. Scrolls only when needed to keep the focused input above the keyboard. */
|
|
19
22
|
scrollable?: boolean;
|
|
20
23
|
/** Optional full-screen background image. Renders behind all content. */
|
|
@@ -31,12 +34,16 @@ const ScreenWrapper: React.FC<ScreenWrapperProps> = ({
|
|
|
31
34
|
children,
|
|
32
35
|
className = '',
|
|
33
36
|
showLoader = false,
|
|
37
|
+
isModal = false,
|
|
34
38
|
scrollable = false,
|
|
35
39
|
backgroundImage,
|
|
36
40
|
backgroundImageClassName,
|
|
37
41
|
backgroundImageResizeMode = 'cover',
|
|
38
42
|
keyboardBottomOffset = 20,
|
|
39
43
|
}) => {
|
|
44
|
+
useScreenLoader(showLoader && !isModal);
|
|
45
|
+
const modalLoader = isModal ? <FullScreenLoader inModal visible={showLoader} /> : null;
|
|
46
|
+
|
|
40
47
|
const insets = useSafeAreaInsets();
|
|
41
48
|
const bottomInset = { paddingBottom: insets.bottom };
|
|
42
49
|
|
|
@@ -60,24 +67,21 @@ const ScreenWrapper: React.FC<ScreenWrapperProps> = ({
|
|
|
60
67
|
</View>
|
|
61
68
|
);
|
|
62
69
|
|
|
63
|
-
const body = backgroundImage ? (
|
|
64
|
-
<ImageBackground
|
|
65
|
-
className="flex-1 bg-background"
|
|
66
|
-
imageClassName={backgroundImageClassName}
|
|
67
|
-
resizeMode={backgroundImageResizeMode}
|
|
68
|
-
source={backgroundImage}
|
|
69
|
-
>
|
|
70
|
-
{content}
|
|
71
|
-
</ImageBackground>
|
|
72
|
-
) : (
|
|
73
|
-
<View className="flex-1 bg-background">{content}</View>
|
|
74
|
-
);
|
|
75
|
-
|
|
76
70
|
return (
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
71
|
+
<View className="flex-1 bg-background">
|
|
72
|
+
{backgroundImage ? (
|
|
73
|
+
<Image
|
|
74
|
+
accessibilityIgnoresInvertColors
|
|
75
|
+
// The explicit size overrides the file's own dimensions, which `Image`
|
|
76
|
+
// applies to a `require`d source and which would otherwise beat `inset-0`.
|
|
77
|
+
className={`absolute h-full w-full ${backgroundImageClassName ?? ''}`}
|
|
78
|
+
resizeMode={backgroundImageResizeMode}
|
|
79
|
+
source={backgroundImage}
|
|
80
|
+
/>
|
|
81
|
+
) : null}
|
|
82
|
+
{content}
|
|
83
|
+
{modalLoader}
|
|
84
|
+
</View>
|
|
81
85
|
);
|
|
82
86
|
};
|
|
83
87
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-expo-boilerplate",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "Create a production-ready Expo and React Native app in one command: Expo Router or React Navigation, Zustand or Redux Toolkit, Unistyles, StyleSheet or Tailwind, TypeScript or JavaScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-expo-boilerplate": "dist/index.js"
|
|
@@ -24,15 +24,33 @@
|
|
|
24
24
|
},
|
|
25
25
|
"repository": {
|
|
26
26
|
"type": "git",
|
|
27
|
-
"url": "git+https://github.com/EndLess728/
|
|
27
|
+
"url": "git+https://github.com/EndLess728/react-native-expo-boilerplate.git",
|
|
28
28
|
"directory": "cli"
|
|
29
29
|
},
|
|
30
|
+
"homepage": "https://endless728.github.io/react-native-expo-boilerplate/",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/EndLess728/react-native-expo-boilerplate/issues"
|
|
33
|
+
},
|
|
30
34
|
"keywords": [
|
|
31
35
|
"expo",
|
|
32
36
|
"react-native",
|
|
33
37
|
"boilerplate",
|
|
38
|
+
"starter",
|
|
34
39
|
"template",
|
|
35
|
-
"create"
|
|
40
|
+
"create",
|
|
41
|
+
"create-expo-app",
|
|
42
|
+
"expo-router",
|
|
43
|
+
"react-navigation",
|
|
44
|
+
"zustand",
|
|
45
|
+
"redux-toolkit",
|
|
46
|
+
"tanstack-query",
|
|
47
|
+
"unistyles",
|
|
48
|
+
"tailwind",
|
|
49
|
+
"uniwind",
|
|
50
|
+
"typescript",
|
|
51
|
+
"mmkv",
|
|
52
|
+
"i18n",
|
|
53
|
+
"eas"
|
|
36
54
|
],
|
|
37
55
|
"author": "EndLess728",
|
|
38
56
|
"license": "MIT",
|
package/template/.easignore
CHANGED
package/template/.env.example
CHANGED
|
@@ -42,6 +42,3 @@ EXPO_PUBLIC_APP_ENV=development
|
|
|
42
42
|
# .env.development is seeded with https://dummyjson.com/ (a free demo API) so
|
|
43
43
|
# the Home screen's posts example works out of the box. Swap it for your own.
|
|
44
44
|
EXPO_PUBLIC_API_URL=https://your-api.example.com/
|
|
45
|
-
|
|
46
|
-
# WebSocket server URL (must be a valid http(s) URL).
|
|
47
|
-
EXPO_PUBLIC_SOCKET_URL=https://your-socket.example.com/
|
|
@@ -326,7 +326,7 @@ These are the call-site idioms you'll see in `app/(tabs)/home.tsx` etc. — repe
|
|
|
326
326
|
**Loading + error states:**
|
|
327
327
|
```tsx
|
|
328
328
|
const { data, isLoading, error } = usePosts();
|
|
329
|
-
|
|
329
|
+
return <ScreenWrapper showLoader={isLoading}>{/* screen content */}</ScreenWrapper>;
|
|
330
330
|
// Error toast is already shown by the Axios interceptor; render an empty/retry state if needed.
|
|
331
331
|
```
|
|
332
332
|
|
|
@@ -119,7 +119,7 @@ Before building anything new, check `src/shared/components/`:
|
|
|
119
119
|
| `Button` | Any tappable CTA. Variants: `primary`, `secondary`, `disabled`. Supports `leftIcon` / `rightIcon` / `isLoading`. |
|
|
120
120
|
| `TextField` | Uncontrolled text input with focus animation, error border, optional left/end icons. |
|
|
121
121
|
| `ControlledTextField` | `react-hook-form`-bound `TextField`. Use this in forms — never wire `TextField` to RHF directly. |
|
|
122
|
-
| `FullScreenLoader` |
|
|
122
|
+
| `FullScreenLoader` | Spinner overlay that blocks touches beneath it. On screens, use `ScreenWrapper`'s `showLoader`. In a modal, add `isModal` to `ScreenWrapper`, or render `<FullScreenLoader inModal visible={isLoading} />` as the last child of the view to cover. Give a view with rounded corners hidden overflow. |
|
|
123
123
|
| `TabBarIcon` / `TabBarLabel` | Tab navigator UI bits. |
|
|
124
124
|
|
|
125
125
|
If a new pattern recurs ≥3 times across screens, lift it into `src/shared/components/` rather than copy-pasting JSX.
|
|
@@ -143,7 +143,8 @@ Props:
|
|
|
143
143
|
- `style` — layout style (padding, `justifyContent`, `alignItems`, etc.). Routed to the content container in scrollable mode, or the outer `View` otherwise. **Caller never thinks about which.**
|
|
144
144
|
<!-- @cli:ui-wrapper-style-prop-end -->
|
|
145
145
|
- `scrollable` — opt-in when the screen has text inputs or content that may exceed the viewport. Uses `KeyboardAwareScrollView` from `react-native-keyboard-controller`, which scrolls **only when the focused input would be obscured** by the keyboard. Centered layouts stay centered when there is room.
|
|
146
|
-
- `showLoader` —
|
|
146
|
+
- `showLoader` — shows the app-wide `FullScreenLoader` above the tab bar and header while this screen is focused. A screen loading in the background doesn't show it.
|
|
147
|
+
- `isModal` — set on modal screens and inside a React Native `<Modal>`. `showLoader` then covers only this content, and the modal stays solid instead of being dimmed.
|
|
147
148
|
- `backgroundImage` / `backgroundImageStyle` / `backgroundImageResizeMode` — full-screen background art.
|
|
148
149
|
- `keyboardBottomOffset` — gap between the keyboard top and the focused input (default `20`).
|
|
149
150
|
|
package/template/cliff.toml
CHANGED
package/template/env.ts
CHANGED
|
@@ -33,7 +33,6 @@ const envSchema = z.object({
|
|
|
33
33
|
|
|
34
34
|
// Runtime variables (from .env.* files)
|
|
35
35
|
EXPO_PUBLIC_API_URL: z.string().url('EXPO_PUBLIC_API_URL must be a valid URL'),
|
|
36
|
-
EXPO_PUBLIC_SOCKET_URL: z.string().url('EXPO_PUBLIC_SOCKET_URL must be a valid URL'),
|
|
37
36
|
});
|
|
38
37
|
|
|
39
38
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -86,7 +85,6 @@ const _env: z.infer<typeof envSchema> = {
|
|
|
86
85
|
EXPO_PUBLIC_PACKAGE_NAME: PACKAGE_NAME,
|
|
87
86
|
EXPO_PUBLIC_VERSION: packageJSON.version,
|
|
88
87
|
EXPO_PUBLIC_API_URL: process.env.EXPO_PUBLIC_API_URL ?? '',
|
|
89
|
-
EXPO_PUBLIC_SOCKET_URL: process.env.EXPO_PUBLIC_SOCKET_URL ?? '',
|
|
90
88
|
};
|
|
91
89
|
|
|
92
90
|
// ─────────────────────────────────────────────────────────────
|
package/template/jest.config.js
CHANGED
|
@@ -14,9 +14,9 @@ module.exports = {
|
|
|
14
14
|
')/)',
|
|
15
15
|
],
|
|
16
16
|
// `cli/` is the create-expo-boilerplate package — its overlays have their
|
|
17
|
-
// own tests that only run inside generated projects.
|
|
18
|
-
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/cli/'],
|
|
19
|
-
modulePathIgnorePatterns: ['<rootDir>/cli/'],
|
|
17
|
+
// own tests that only run inside generated projects. `website/` is the docs site.
|
|
18
|
+
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/cli/', '<rootDir>/website/'],
|
|
19
|
+
modulePathIgnorePatterns: ['<rootDir>/cli/', '<rootDir>/website/'],
|
|
20
20
|
moduleNameMapper: {
|
|
21
21
|
'^@/(.*)$': '<rootDir>/src/$1',
|
|
22
22
|
'^@env$': '<rootDir>/__mocks__/@env.ts',
|
|
@@ -2,12 +2,11 @@ import React from 'react';
|
|
|
2
2
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
3
3
|
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
|
4
4
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
5
|
-
import Toast from 'react-native-toast-message';
|
|
6
5
|
import { NavigationBar } from 'expo-navigation-bar';
|
|
7
6
|
import { StatusBar } from 'expo-status-bar';
|
|
8
7
|
|
|
9
8
|
import { APIProvider } from '@/services/api-provider';
|
|
10
|
-
import {
|
|
9
|
+
import { AppOverlay } from '@/shared/components/AppOverlay';
|
|
11
10
|
import { StoreProvider } from '@/store';
|
|
12
11
|
import { useAppTheme } from '@/theme';
|
|
13
12
|
|
|
@@ -31,7 +30,7 @@ export function AppProviders({ children }: { children: React.ReactNode }): React
|
|
|
31
30
|
</StoreProvider>
|
|
32
31
|
</SafeAreaProvider>
|
|
33
32
|
</KeyboardProvider>
|
|
34
|
-
<
|
|
33
|
+
<AppOverlay />
|
|
35
34
|
</GestureHandlerRootView>
|
|
36
35
|
</>
|
|
37
36
|
);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Platform, View, type ViewStyle } from 'react-native';
|
|
3
|
+
import { FullWindowOverlay } from 'react-native-screens';
|
|
4
|
+
import Toast from 'react-native-toast-message';
|
|
5
|
+
|
|
6
|
+
import FullScreenLoader from '@/shared/components/FullScreenLoader';
|
|
7
|
+
import { useIsAppLoaderVisible } from '@/shared/utils/appLoader';
|
|
8
|
+
import { toastConfig } from '@/shared/utils/toast';
|
|
9
|
+
|
|
10
|
+
// On iOS, native modal screens are presented above the app's root view, so toasts
|
|
11
|
+
// need FullWindowOverlay to stay visible over them. On Android they share the root
|
|
12
|
+
// view, and rendering last is enough.
|
|
13
|
+
const Container = Platform.OS === 'ios' ? FullWindowOverlay : React.Fragment;
|
|
14
|
+
|
|
15
|
+
// A plain object: this file is shared by every styling option.
|
|
16
|
+
const fillParent: ViewStyle = { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 };
|
|
17
|
+
|
|
18
|
+
/** App-wide layer above the navigator: the loader, with toasts on top of it. */
|
|
19
|
+
export function AppOverlay(): React.JSX.Element {
|
|
20
|
+
const isLoaderVisible = useIsAppLoaderVisible();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Container>
|
|
24
|
+
{/* FullWindowOverlay stacks children in mount order, ignoring their order
|
|
25
|
+
here, so this one view keeps the toast above a loader that mounts later. */}
|
|
26
|
+
<View collapsable={false} pointerEvents="box-none" style={fillParent}>
|
|
27
|
+
<FullScreenLoader visible={isLoaderVisible} />
|
|
28
|
+
<Toast config={toastConfig} position="top" />
|
|
29
|
+
</View>
|
|
30
|
+
</Container>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -1,50 +1,64 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { ActivityIndicator,
|
|
2
|
+
import { ActivityIndicator, Text, View } from 'react-native';
|
|
3
3
|
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
|
|
4
4
|
|
|
5
5
|
import { translate } from '@/localization/utils';
|
|
6
6
|
import { ms } from '@/shared/utils';
|
|
7
7
|
import { TextStyles } from '@/theme';
|
|
8
8
|
|
|
9
|
-
interface
|
|
9
|
+
interface FullScreenLoaderProps {
|
|
10
10
|
visible: boolean;
|
|
11
11
|
size?: 'small' | 'large';
|
|
12
12
|
color?: string;
|
|
13
|
+
/** Uses a lighter dim and theme-colored text, so a modal still looks solid. */
|
|
14
|
+
inModal?: boolean;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Loading overlay that fills its parent and blocks touches on what's beneath it.
|
|
19
|
+
*
|
|
20
|
+
* It is a plain view rather than a native `Modal`, so it can't get stuck over the
|
|
21
|
+
* app. Screens get it through `ScreenWrapper`'s `showLoader`. Inside a modal, pass
|
|
22
|
+
* `isModal` to `ScreenWrapper`, or render it here with `inModal` as the last child
|
|
23
|
+
* of the view to cover (give a view with rounded corners `overflow: 'hidden'`).
|
|
24
|
+
*/
|
|
25
|
+
const FullScreenLoader: React.FC<FullScreenLoaderProps> = ({
|
|
16
26
|
visible = false,
|
|
17
27
|
size = 'large',
|
|
18
28
|
color,
|
|
29
|
+
inModal = false,
|
|
19
30
|
}) => {
|
|
20
31
|
const { theme } = useUnistyles();
|
|
21
32
|
|
|
33
|
+
if (!visible) return null;
|
|
34
|
+
|
|
35
|
+
const label = translate('common.loading');
|
|
36
|
+
|
|
22
37
|
return (
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
<View
|
|
39
|
+
accessibilityViewIsModal
|
|
40
|
+
accessible
|
|
41
|
+
accessibilityLabel={label}
|
|
42
|
+
accessibilityRole="progressbar"
|
|
43
|
+
style={styles.overlay}
|
|
44
|
+
>
|
|
45
|
+
<View style={inModal ? styles.modalBackdrop : styles.screenBackdrop} />
|
|
46
|
+
<ActivityIndicator color={color || theme.colors.primary} size={size} />
|
|
47
|
+
<Text style={[TextStyles.label, inModal ? styles.modalText : styles.screenText]}>
|
|
48
|
+
{label}
|
|
49
|
+
</Text>
|
|
50
|
+
</View>
|
|
31
51
|
);
|
|
32
52
|
};
|
|
33
53
|
|
|
54
|
+
const fill = { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 } as const;
|
|
55
|
+
|
|
34
56
|
const styles = StyleSheet.create((theme) => ({
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
activityIndicatorWrapper: {
|
|
42
|
-
padding: ms(20),
|
|
43
|
-
borderRadius: ms(10),
|
|
44
|
-
alignItems: 'center',
|
|
45
|
-
justifyContent: 'center',
|
|
46
|
-
},
|
|
47
|
-
loadingText: { color: theme.colors.white, marginTop: ms(10) },
|
|
57
|
+
overlay: { ...fill, justifyContent: 'center', alignItems: 'center' },
|
|
58
|
+
screenBackdrop: { ...fill, backgroundColor: theme.colors.opacity50 },
|
|
59
|
+
modalBackdrop: { ...fill, backgroundColor: 'rgba(0,0,0,0.35)' },
|
|
60
|
+
screenText: { color: theme.colors.white, marginTop: ms(10) },
|
|
61
|
+
modalText: { color: theme.colors.typography, marginTop: ms(10) },
|
|
48
62
|
}));
|
|
49
63
|
|
|
50
|
-
export default
|
|
64
|
+
export default FullScreenLoader;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
Image,
|
|
4
4
|
type ImageResizeMode,
|
|
5
5
|
type ImageSourcePropType,
|
|
6
6
|
type ImageStyle,
|
|
@@ -10,13 +10,16 @@ import {
|
|
|
10
10
|
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
|
|
11
11
|
import { StyleSheet } from 'react-native-unistyles';
|
|
12
12
|
|
|
13
|
-
import
|
|
13
|
+
import FullScreenLoader from '@/shared/components/FullScreenLoader';
|
|
14
|
+
import { useScreenLoader } from '@/shared/hooks/useScreenLoader';
|
|
14
15
|
|
|
15
16
|
interface ScreenWrapperProps {
|
|
16
17
|
children: React.ReactNode;
|
|
17
18
|
/** Layout style (padding, justifyContent, alignItems, etc.). Routed to the content container in scrollable mode, or the outer View otherwise. */
|
|
18
19
|
style?: ViewStyle;
|
|
19
20
|
showLoader?: boolean;
|
|
21
|
+
/** Set on modal screens, and inside a React Native `<Modal>`: `showLoader` then covers only this content, not the whole app. */
|
|
22
|
+
isModal?: boolean;
|
|
20
23
|
/** Enable for any screen with text inputs. Scrolls only when needed to keep the focused input above the keyboard. */
|
|
21
24
|
scrollable?: boolean;
|
|
22
25
|
/** Optional full-screen background image. Renders behind all content. */
|
|
@@ -33,12 +36,16 @@ const ScreenWrapper: React.FC<ScreenWrapperProps> = ({
|
|
|
33
36
|
children,
|
|
34
37
|
style,
|
|
35
38
|
showLoader = false,
|
|
39
|
+
isModal = false,
|
|
36
40
|
scrollable = false,
|
|
37
41
|
backgroundImage,
|
|
38
42
|
backgroundImageStyle,
|
|
39
43
|
backgroundImageResizeMode = 'cover',
|
|
40
44
|
keyboardBottomOffset = 20,
|
|
41
45
|
}) => {
|
|
46
|
+
useScreenLoader(showLoader && !isModal);
|
|
47
|
+
const modalLoader = isModal ? <FullScreenLoader inModal visible={showLoader} /> : null;
|
|
48
|
+
|
|
42
49
|
const content = scrollable ? (
|
|
43
50
|
<KeyboardAwareScrollView
|
|
44
51
|
bottomOffset={keyboardBottomOffset}
|
|
@@ -53,24 +60,19 @@ const ScreenWrapper: React.FC<ScreenWrapperProps> = ({
|
|
|
53
60
|
<View style={[styles.viewContainer, style]}>{children}</View>
|
|
54
61
|
);
|
|
55
62
|
|
|
56
|
-
const body = backgroundImage ? (
|
|
57
|
-
<ImageBackground
|
|
58
|
-
imageStyle={backgroundImageStyle}
|
|
59
|
-
resizeMode={backgroundImageResizeMode}
|
|
60
|
-
source={backgroundImage}
|
|
61
|
-
style={styles.wrapper}
|
|
62
|
-
>
|
|
63
|
-
{content}
|
|
64
|
-
</ImageBackground>
|
|
65
|
-
) : (
|
|
66
|
-
<View style={styles.wrapper}>{content}</View>
|
|
67
|
-
);
|
|
68
|
-
|
|
69
63
|
return (
|
|
70
|
-
|
|
71
|
-
{
|
|
72
|
-
|
|
73
|
-
|
|
64
|
+
<View style={styles.wrapper}>
|
|
65
|
+
{backgroundImage ? (
|
|
66
|
+
<Image
|
|
67
|
+
accessibilityIgnoresInvertColors
|
|
68
|
+
resizeMode={backgroundImageResizeMode}
|
|
69
|
+
source={backgroundImage}
|
|
70
|
+
style={[styles.backgroundImage, backgroundImageStyle]}
|
|
71
|
+
/>
|
|
72
|
+
) : null}
|
|
73
|
+
{content}
|
|
74
|
+
{modalLoader}
|
|
75
|
+
</View>
|
|
74
76
|
);
|
|
75
77
|
};
|
|
76
78
|
|
|
@@ -81,6 +83,13 @@ const styles = StyleSheet.create((theme, rt) => ({
|
|
|
81
83
|
flex: 1,
|
|
82
84
|
backgroundColor: theme.colors.background,
|
|
83
85
|
},
|
|
86
|
+
// The explicit size overrides the file's own dimensions, which `Image` applies
|
|
87
|
+
// to a `require`d source and which would otherwise beat the absolute edges.
|
|
88
|
+
backgroundImage: {
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
width: '100%',
|
|
91
|
+
height: '100%',
|
|
92
|
+
},
|
|
84
93
|
container: {
|
|
85
94
|
flex: 1,
|
|
86
95
|
},
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useIsFocused } from 'expo-router';
|
|
3
|
+
|
|
4
|
+
import { requestAppLoader } from '@/shared/utils/appLoader';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shows the app-wide loader while `active` is true and this screen is focused,
|
|
8
|
+
* so a tab or screen loading in the background never blocks the one in view.
|
|
9
|
+
*/
|
|
10
|
+
export function useScreenLoader(active: boolean): void {
|
|
11
|
+
const isFocused = useIsFocused();
|
|
12
|
+
const shouldShow = active && isFocused;
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!shouldShow) return;
|
|
16
|
+
return requestAppLoader();
|
|
17
|
+
}, [shouldShow]);
|
|
18
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react-native';
|
|
2
|
+
|
|
3
|
+
import { requestAppLoader, useIsAppLoaderVisible } from '@/shared/utils/appLoader';
|
|
4
|
+
|
|
5
|
+
describe('appLoader', () => {
|
|
6
|
+
it('stays visible until every request is released', () => {
|
|
7
|
+
const { result } = renderHook(() => useIsAppLoaderVisible());
|
|
8
|
+
expect(result.current).toBe(false);
|
|
9
|
+
|
|
10
|
+
let releaseFirst = () => {};
|
|
11
|
+
let releaseSecond = () => {};
|
|
12
|
+
act(() => {
|
|
13
|
+
releaseFirst = requestAppLoader();
|
|
14
|
+
releaseSecond = requestAppLoader();
|
|
15
|
+
});
|
|
16
|
+
expect(result.current).toBe(true);
|
|
17
|
+
|
|
18
|
+
act(() => releaseFirst());
|
|
19
|
+
expect(result.current).toBe(true);
|
|
20
|
+
|
|
21
|
+
act(() => releaseSecond());
|
|
22
|
+
expect(result.current).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('ignores a second release of the same request', () => {
|
|
26
|
+
const { result } = renderHook(() => useIsAppLoaderVisible());
|
|
27
|
+
|
|
28
|
+
let releaseFirst = () => {};
|
|
29
|
+
let releaseSecond = () => {};
|
|
30
|
+
act(() => {
|
|
31
|
+
releaseFirst = requestAppLoader();
|
|
32
|
+
releaseSecond = requestAppLoader();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
act(() => {
|
|
36
|
+
releaseFirst();
|
|
37
|
+
releaseFirst();
|
|
38
|
+
});
|
|
39
|
+
expect(result.current).toBe(true);
|
|
40
|
+
|
|
41
|
+
act(() => releaseSecond());
|
|
42
|
+
expect(result.current).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
// How many screens currently want the app-wide loader. A count rather than a
|
|
4
|
+
// boolean, so one screen finishing can't hide the loader for another.
|
|
5
|
+
let activeRequests = 0;
|
|
6
|
+
const listeners = new Set<() => void>();
|
|
7
|
+
|
|
8
|
+
const notify = () => listeners.forEach((listener) => listener());
|
|
9
|
+
|
|
10
|
+
const subscribe = (listener: () => void) => {
|
|
11
|
+
listeners.add(listener);
|
|
12
|
+
return () => {
|
|
13
|
+
listeners.delete(listener);
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Shows the app-wide loader until the returned function is called. */
|
|
18
|
+
export function requestAppLoader(): () => void {
|
|
19
|
+
activeRequests += 1;
|
|
20
|
+
notify();
|
|
21
|
+
|
|
22
|
+
let released = false;
|
|
23
|
+
return () => {
|
|
24
|
+
if (released) return;
|
|
25
|
+
released = true;
|
|
26
|
+
activeRequests -= 1;
|
|
27
|
+
notify();
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useIsAppLoaderVisible(): boolean {
|
|
32
|
+
return useSyncExternalStore(subscribe, () => activeRequests > 0);
|
|
33
|
+
}
|
package/template/tsconfig.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { Dimensions, Platform } from 'react-native';
|
|
2
|
-
import Constants from 'expo-constants';
|
|
3
|
-
|
|
4
|
-
export const { width: windowWidth, height: windowHeight } = Dimensions.get('window');
|
|
5
|
-
|
|
6
|
-
export const { version, ios, android } = Constants?.expoConfig ?? {};
|
|
7
|
-
|
|
8
|
-
export const OS = Platform.OS;
|
|
9
|
-
export const isWeb = Platform.OS === 'web';
|
|
10
|
-
export const isIOS = Platform.OS === 'ios';
|
|
11
|
-
export const isAndroid = Platform.OS === 'android';
|
|
File without changes
|