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.
@@ -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, Modal, Text, View } from 'react-native';
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 FullscreenLoaderProps {
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
- const FullscreenLoader: React.FC<FullscreenLoaderProps> = ({
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
- <Modal statusBarTranslucent transparent animationType="fade" visible={visible}>
24
- <View style={styles.modalBackground}>
25
- <View style={styles.activityIndicatorWrapper}>
26
- <ActivityIndicator color={color || theme.colors.primary} size={size} />
27
- <Text style={[TextStyles.label, styles.loadingText]}>{translate('common.loading')}</Text>
28
- </View>
29
- </View>
30
- </Modal>
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
- modalBackground: {
36
- flex: 1,
37
- justifyContent: 'center',
38
- alignItems: 'center',
39
- backgroundColor: theme.colors.opacity50,
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 FullscreenLoader;
64
+ export default FullScreenLoader;
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import {
3
- ImageBackground,
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 FullscreenLoader from '@/shared/components/FullScreenLoader';
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
- {body}
79
- <FullscreenLoader visible={showLoader} />
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, Modal, Text, View } from 'react-native';
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 FullscreenLoaderProps {
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
- const FullscreenLoader: React.FC<FullscreenLoaderProps> = ({
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
- <Modal statusBarTranslucent transparent animationType="fade" visible={visible}>
22
- <View className="flex-1 items-center justify-center bg-opacity50">
23
- <View className="items-center justify-center rounded-[10px] p-5">
24
- <ActivityIndicator color={color || theme.colors.primary} size={size} />
25
- <Text className="text-label font-opensans-bold mt-2.5 text-white">
26
- {translate('common.loading')}
27
- </Text>
28
- </View>
29
- </View>
30
- </Modal>
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 FullscreenLoader;
54
+ export default FullScreenLoader;
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import {
3
- ImageBackground,
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 FullscreenLoader from '@/shared/components/FullScreenLoader';
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
- {body}
79
- <FullscreenLoader visible={showLoader} />
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.0",
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": {
@@ -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
- if (isLoading) return <FullScreenLoader />;
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` | Modal spinner overlay — usually triggered via `ScreenWrapper`'s `showLoader` prop. |
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` — toggles full-screen `ActivityIndicator` overlay.
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
 
@@ -5,7 +5,6 @@ const Env = {
5
5
  EXPO_PUBLIC_PACKAGE_NAME: 'com.expo.template',
6
6
  EXPO_PUBLIC_VERSION: '1.0.0',
7
7
  EXPO_PUBLIC_API_URL: 'https://test-api.example.com/',
8
- EXPO_PUBLIC_SOCKET_URL: 'https://test-socket.example.com',
9
8
  };
10
9
 
11
10
  export default Env;
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 { toastConfig } from '@/shared/utils/toast';
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
- <Toast config={toastConfig} position="top" />
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, Modal, Text, View } from 'react-native';
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 FullscreenLoaderProps {
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
- const FullscreenLoader: React.FC<FullscreenLoaderProps> = ({
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
- <Modal statusBarTranslucent transparent animationType="fade" visible={visible}>
24
- <View style={styles.modalBackground}>
25
- <View style={styles.activityIndicatorWrapper}>
26
- <ActivityIndicator color={color || theme.colors.primary} size={size} />
27
- <Text style={[TextStyles.label, styles.loadingText]}>{translate('common.loading')}</Text>
28
- </View>
29
- </View>
30
- </Modal>
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
- modalBackground: {
36
- flex: 1,
37
- justifyContent: 'center',
38
- alignItems: 'center',
39
- backgroundColor: theme.colors.opacity50,
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 FullscreenLoader;
64
+ export default FullScreenLoader;
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import {
3
- ImageBackground,
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 FullscreenLoader from '@/shared/components/FullScreenLoader';
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
- {body}
72
- <FullscreenLoader visible={showLoader} />
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
  },
@@ -1,3 +1 @@
1
- export * as DeviceInfo from '@/shared/constants/deviceInfo';
2
1
  export { NAVIGATION } from '@/shared/constants/navigation';
3
- export { STATUS } from '@/shared/constants/status';
@@ -1,3 +1,4 @@
1
1
  export { useAppState, useOnAppForeground } from './useAppState';
2
2
  export { useBlockBackNavigation } from './useBlockBackNavigation';
3
3
  export { useDebounce } from './useDebounce';
4
+ export { useScreenLoader } from './useScreenLoader';
@@ -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,4 +0,0 @@
1
- import Env from '@env';
2
-
3
- export const BASE_URL = Env.EXPO_PUBLIC_API_URL;
4
- export const SOCKET_URL = Env.EXPO_PUBLIC_SOCKET_URL;
@@ -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';