create-expo-boilerplate 1.1.0 → 1.1.1
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/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 +1 -1
- 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/env.ts +0 -2
- 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/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
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-expo-boilerplate",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Scaffold a production-ready Expo app from the React-Native-Expo-Boilterplate, with your choice of state management, navigation, language and tooling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
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/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
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -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
|
+
}
|
|
@@ -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
|