create-react-native-starter-kit 0.0.0-stage → 1.0.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.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -2
  3. package/bin/create-react-native-starter-kit.js +95 -0
  4. package/lib/copy-src.js +127 -0
  5. package/lib/creator.js +211 -0
  6. package/lib/deps.js +58 -0
  7. package/lib/patch-configs.js +318 -0
  8. package/lib/patch-native.js +588 -0
  9. package/package.json +47 -4
  10. package/scripts/sync-template.js +52 -0
  11. package/template/.env.example +3 -0
  12. package/template/patches/react-native-element-dropdown+2.12.4.patch +67 -0
  13. package/template/src/app/App.tsx +25 -0
  14. package/template/src/app/navigation/root.tsx +24 -0
  15. package/template/src/app/navigation/stacks/app.stack.tsx +67 -0
  16. package/template/src/app/navigation/stacks/app.tab.tsx +72 -0
  17. package/template/src/app/navigation/stacks/auth.stack.tsx +56 -0
  18. package/template/src/app/providers.tsx +43 -0
  19. package/template/src/assets/fonts/InterTight-Black.ttf +0 -0
  20. package/template/src/assets/fonts/InterTight-BlackItalic.ttf +0 -0
  21. package/template/src/assets/fonts/InterTight-Bold.ttf +0 -0
  22. package/template/src/assets/fonts/InterTight-BoldItalic.ttf +0 -0
  23. package/template/src/assets/fonts/InterTight-ExtraBold.ttf +0 -0
  24. package/template/src/assets/fonts/InterTight-ExtraBoldItalic.ttf +0 -0
  25. package/template/src/assets/fonts/InterTight-ExtraLight.ttf +0 -0
  26. package/template/src/assets/fonts/InterTight-ExtraLightItalic.ttf +0 -0
  27. package/template/src/assets/fonts/InterTight-Italic.ttf +0 -0
  28. package/template/src/assets/fonts/InterTight-Light.ttf +0 -0
  29. package/template/src/assets/fonts/InterTight-LightItalic.ttf +0 -0
  30. package/template/src/assets/fonts/InterTight-Medium.ttf +0 -0
  31. package/template/src/assets/fonts/InterTight-MediumItalic.ttf +0 -0
  32. package/template/src/assets/fonts/InterTight-Regular.ttf +0 -0
  33. package/template/src/assets/fonts/InterTight-SemiBold.ttf +0 -0
  34. package/template/src/assets/fonts/InterTight-SemiBoldItalic.ttf +0 -0
  35. package/template/src/assets/fonts/InterTight-Thin.ttf +0 -0
  36. package/template/src/assets/fonts/InterTight-ThinItalic.ttf +0 -0
  37. package/template/src/assets/icons/back.svg +3 -0
  38. package/template/src/assets/icons/calendar.svg +3 -0
  39. package/template/src/assets/icons/claim_upload.svg +3 -0
  40. package/template/src/assets/icons/clock.svg +1 -0
  41. package/template/src/assets/icons/downArrow.svg +3 -0
  42. package/template/src/assets/icons/edit.svg +3 -0
  43. package/template/src/assets/icons/eye.svg +4 -0
  44. package/template/src/assets/icons/eyeClose.svg +7 -0
  45. package/template/src/assets/icons/folder.svg +5 -0
  46. package/template/src/assets/icons/home.svg +4 -0
  47. package/template/src/assets/icons/index.ts +35 -0
  48. package/template/src/assets/icons/log_out.svg +4 -0
  49. package/template/src/assets/icons/setting.svg +4 -0
  50. package/template/src/assets/icons/tabs/home/filled_dark.svg +3 -0
  51. package/template/src/assets/icons/tabs/home/filled_light.svg +3 -0
  52. package/template/src/assets/icons/tabs/home/outline.svg +4 -0
  53. package/template/src/assets/icons/tabs/index.ts +31 -0
  54. package/template/src/assets/icons/tabs/profile/filled_dark.svg +6 -0
  55. package/template/src/assets/icons/tabs/profile/filled_light.svg +6 -0
  56. package/template/src/assets/icons/tabs/profile/outline.svg +3 -0
  57. package/template/src/assets/icons/tooltip_dark.svg +1 -0
  58. package/template/src/assets/icons/tooltip_light.svg +1 -0
  59. package/template/src/assets/icons/upArrow.svg +3 -0
  60. package/template/src/assets/images/back.png +0 -0
  61. package/template/src/assets/images/index.ts +7 -0
  62. package/template/src/assets/images/placeholder/profile.png +0 -0
  63. package/template/src/components/shared/AppImage.tsx +190 -0
  64. package/template/src/components/shared/AvatarPicker.tsx +175 -0
  65. package/template/src/components/shared/CustomButton.tsx +273 -0
  66. package/template/src/components/shared/DocumentPicker.tsx +86 -0
  67. package/template/src/components/shared/GlobalLoader.tsx +47 -0
  68. package/template/src/components/shared/Icon.tsx +28 -0
  69. package/template/src/components/shared/PressableIcon.tsx +104 -0
  70. package/template/src/components/shared/bottom-sheet/BaseBottomSheet.tsx +30 -0
  71. package/template/src/components/shared/bottom-sheet/BaseBottomSheetModal.tsx +50 -0
  72. package/template/src/components/shared/bottom-sheet/sheets/ImagePickerSheet.tsx +310 -0
  73. package/template/src/components/shared/bottom-sheet/types.ts +4 -0
  74. package/template/src/components/shared/form-input/FormInput.tsx +140 -0
  75. package/template/src/components/shared/form-input/FormInputRenderer.tsx +82 -0
  76. package/template/src/components/shared/form-input/FullNameInput.tsx +243 -0
  77. package/template/src/components/shared/form-input/formFieldStyles.ts +43 -0
  78. package/template/src/components/shared/form-input/type.ts +188 -0
  79. package/template/src/components/shared/form-input/variants/CountryPhoneVariant.tsx +289 -0
  80. package/template/src/components/shared/form-input/variants/DateTimeVariant.tsx +280 -0
  81. package/template/src/components/shared/form-input/variants/DropdownVariant.tsx +221 -0
  82. package/template/src/components/shared/form-input/variants/TextInputVariant.tsx +300 -0
  83. package/template/src/components/shared/form-input/variants/ToggleVariant.tsx +194 -0
  84. package/template/src/components/shared/modal/BaseModal.tsx +83 -0
  85. package/template/src/components/shared/modal/common/AlertModal.tsx +173 -0
  86. package/template/src/components/shared/navigation/AnimatedTabIcon.tsx +37 -0
  87. package/template/src/components/shared/navigation/HeaderActionButton.tsx +70 -0
  88. package/template/src/components/shared/navigation/HeaderBackButton.tsx +38 -0
  89. package/template/src/components/shared/navigation/StackHeader.tsx +105 -0
  90. package/template/src/components/shared/navigation/tabIconRender.tsx +14 -0
  91. package/template/src/components/shared/offline/OfflineQueryBoundary.tsx +25 -0
  92. package/template/src/components/shared/offline/OfflineState.tsx +229 -0
  93. package/template/src/components/shared/offline/index.ts +6 -0
  94. package/template/src/components/shared/offline/types.ts +15 -0
  95. package/template/src/components/shared/offline/wifi-icon-animation/BrokenWifiAnimation.tsx +116 -0
  96. package/template/src/components/shared/offline/wifi-icon-animation/WifiPiece.tsx +80 -0
  97. package/template/src/components/shared/offline/wifi-icon-animation/geometry.ts +163 -0
  98. package/template/src/components/shared/offline/wifi-icon-animation/useWifiIconAnimationActive.ts +37 -0
  99. package/template/src/components/shared/skelton/BaseSkelton.tsx +71 -0
  100. package/template/src/components/shared/skelton/ShimmerHolder.ts +6 -0
  101. package/template/src/components/shared/skelton/holders/HomeShimmer.tsx +25 -0
  102. package/template/src/components/shared/skelton/holders/index.ts +1 -0
  103. package/template/src/components/shared/skelton/layouts/avatar.layout.ts +18 -0
  104. package/template/src/components/shared/skelton/layouts/home.layout.ts +16 -0
  105. package/template/src/components/shared/skelton/layouts/index.ts +3 -0
  106. package/template/src/components/shared/skelton/layouts/text.layout.ts +18 -0
  107. package/template/src/components/shared/text/CustomText.tsx +85 -0
  108. package/template/src/components/shared/text/ShimmerText.tsx +159 -0
  109. package/template/src/components/shared/text/types.ts +40 -0
  110. package/template/src/constants/device.ts +12 -0
  111. package/template/src/constants/endpoint.ts +11 -0
  112. package/template/src/constants/keyExtractor.ts +6 -0
  113. package/template/src/constants/limits.ts +9 -0
  114. package/template/src/constants/queryKey.ts +3 -0
  115. package/template/src/constants/regex.ts +7 -0
  116. package/template/src/context/NetworkContext.tsx +102 -0
  117. package/template/src/context/ThemeContext.tsx +56 -0
  118. package/template/src/hooks/app/index.ts +2 -0
  119. package/template/src/hooks/app/useGetProfile.ts +38 -0
  120. package/template/src/hooks/app/useUpdateProfile.ts +21 -0
  121. package/template/src/hooks/useAppDispatch.ts +6 -0
  122. package/template/src/hooks/useAppSelector.ts +4 -0
  123. package/template/src/hooks/useDebounce.ts +15 -0
  124. package/template/src/hooks/useDocumentPicker.ts +102 -0
  125. package/template/src/hooks/useImagePicker.ts +83 -0
  126. package/template/src/i18n/i18next.d.ts +9 -0
  127. package/template/src/i18n/index.ts +16 -0
  128. package/template/src/i18n/locales/en.json +274 -0
  129. package/template/src/i18n/locales/ru.json +165 -0
  130. package/template/src/i18n/resources.ts +11 -0
  131. package/template/src/redux/selectors/auth.selectors.ts +4 -0
  132. package/template/src/redux/slice/auth.slice.ts +23 -0
  133. package/template/src/redux/slice/loader.slice.ts +25 -0
  134. package/template/src/redux/slice/settings.slice.ts +26 -0
  135. package/template/src/redux/slice/user.slice.ts +25 -0
  136. package/template/src/screens/app/main-tab/index.tsx +73 -0
  137. package/template/src/screens/app/main-tab/style.ts +0 -0
  138. package/template/src/screens/app/profile-setup/index.tsx +162 -0
  139. package/template/src/screens/app/profile-setup/styles.ts +3 -0
  140. package/template/src/screens/auth/forgot-password/index.tsx +59 -0
  141. package/template/src/screens/auth/forgot-password/styles.ts +0 -0
  142. package/template/src/screens/auth/index.ts +4 -0
  143. package/template/src/screens/auth/login/index.tsx +138 -0
  144. package/template/src/screens/auth/login/styles.ts +3 -0
  145. package/template/src/screens/auth/signup/index.tsx +111 -0
  146. package/template/src/screens/auth/signup/styles.ts +3 -0
  147. package/template/src/screens/auth/verify-email/index.tsx +15 -0
  148. package/template/src/screens/auth/verify-email/styles.ts +3 -0
  149. package/template/src/screens/tab/home/index.tsx +49 -0
  150. package/template/src/screens/tab/home/styles.ts +3 -0
  151. package/template/src/screens/tab/index.ts +2 -0
  152. package/template/src/screens/tab/profile/index.tsx +50 -0
  153. package/template/src/screens/tab/profile/styles.ts +3 -0
  154. package/template/src/services/api/auth/auth.api.ts +22 -0
  155. package/template/src/services/api/auth/auth.endpoints.ts +7 -0
  156. package/template/src/services/api/auth/auth.types.ts +9 -0
  157. package/template/src/services/api/auth/index.ts +3 -0
  158. package/template/src/services/api/axios/axios.instance.ts +15 -0
  159. package/template/src/services/api/axios/axios.interceptors.ts +43 -0
  160. package/template/src/services/api/axios/index.ts +2 -0
  161. package/template/src/services/api/mock/mockAdapter.ts +97 -0
  162. package/template/src/services/api/user/index.ts +2 -0
  163. package/template/src/services/api/user/user.api.ts +67 -0
  164. package/template/src/services/api/user/user.endpoints.ts +0 -0
  165. package/template/src/services/api/user/user.types.ts +0 -0
  166. package/template/src/services/errors/backendErrorCodes.ts +7 -0
  167. package/template/src/services/errors/errorHandler.ts +94 -0
  168. package/template/src/services/errors/firebaseErrorMap.ts +61 -0
  169. package/template/src/services/errors/httpStatusMap.ts +47 -0
  170. package/template/src/services/errors/index.ts +4 -0
  171. package/template/src/services/network/index.ts +1 -0
  172. package/template/src/services/network/onlineManager.ts +22 -0
  173. package/template/src/services/query/index.ts +1 -0
  174. package/template/src/services/query/queryClient.ts +23 -0
  175. package/template/src/services/toast/ToastCard.tsx +137 -0
  176. package/template/src/services/toast/ToastConfig.tsx +32 -0
  177. package/template/src/services/toast/index.ts +3 -0
  178. package/template/src/services/toast/toast.ts +68 -0
  179. package/template/src/storage/mmkvAdapter.ts +3 -0
  180. package/template/src/storage/mmkvInstance.ts +16 -0
  181. package/template/src/store/index.ts +15 -0
  182. package/template/src/store/mmkv.ts +6 -0
  183. package/template/src/store/persist.ts +10 -0
  184. package/template/src/store/rootReducer.ts +26 -0
  185. package/template/src/theme/dark.ts +40 -0
  186. package/template/src/theme/fonts.ts +20 -0
  187. package/template/src/theme/index.ts +6 -0
  188. package/template/src/theme/layout.ts +23 -0
  189. package/template/src/theme/light.ts +41 -0
  190. package/template/src/theme/palette.ts +32 -0
  191. package/template/src/theme/spacing.ts +7 -0
  192. package/template/src/theme/typography.ts +80 -0
  193. package/template/src/types/api.types.ts +34 -0
  194. package/template/src/types/env.d.ts +6 -0
  195. package/template/src/types/index.ts +2 -0
  196. package/template/src/types/navigation.types.ts +94 -0
  197. package/template/src/utils/clearAppSession.ts +10 -0
  198. package/template/src/utils/date.ts +19 -0
  199. package/template/src/utils/documentPickerHandler.ts +96 -0
  200. package/template/src/utils/getFileNameFromUri.ts +18 -0
  201. package/template/src/utils/getInitials.ts +13 -0
  202. package/template/src/utils/hindiText.ts +33 -0
  203. package/template/src/utils/imageCompress.ts +97 -0
  204. package/template/src/utils/imagePickerHandler.ts +200 -0
  205. package/template/src/utils/maskPhone.ts +17 -0
  206. package/template/src/utils/normalize.ts +12 -0
  207. package/template/src/validation/messages.ts +11 -0
  208. package/template/src/validation/rules.ts +44 -0
  209. package/template/src/validation/schema/auth.schema.ts +53 -0
  210. package/template/src/validation/schema/user.schema.ts +46 -0
  211. package/template/src/validation/tValError.ts +13 -0
@@ -0,0 +1,229 @@
1
+ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import { useTranslation } from 'react-i18next';
4
+ import Haptics from '@mhpdev/react-native-haptics';
5
+ import Animated, {
6
+ Easing,
7
+ useAnimatedStyle,
8
+ useSharedValue,
9
+ withDelay,
10
+ withRepeat,
11
+ withSequence,
12
+ withTiming,
13
+ } from 'react-native-reanimated';
14
+ import CustomButton from '@/components/shared/CustomButton';
15
+ import CustomText from '@/components/shared/text/CustomText';
16
+ import { useNetwork } from '@/context/NetworkContext';
17
+ import { useAppTheme } from '@/context/ThemeContext';
18
+ import { ErrorToast } from '@/services/toast';
19
+ import { ColorType, spacing } from '@/theme';
20
+ import { normalize } from '@/utils/normalize';
21
+ import BrokenWifiAnimation, {
22
+ BREAK_TOTAL_MS,
23
+ } from './wifi-icon-animation/BrokenWifiAnimation';
24
+ import { useWifiIconAnimationActive } from './wifi-icon-animation/useWifiIconAnimationActive';
25
+ import type { OfflineStateProps } from './types';
26
+
27
+ const ENTER_DURATION = 450;
28
+ const NUDGE_DURATION = 1100;
29
+ const NUDGE_REST = 1900;
30
+ const SHAKE_STEP = 55;
31
+
32
+ const OfflineState = ({
33
+ title,
34
+ message,
35
+ showRetry = true,
36
+ }: OfflineStateProps) => {
37
+ const { t } = useTranslation();
38
+ const { color } = useAppTheme();
39
+ const styles = useMemo(() => makeStyles(color), [color]);
40
+ const { refresh } = useNetwork();
41
+ const [checking, setChecking] = useState(false);
42
+ const enter = useSharedValue(0);
43
+ const nudge = useSharedValue(0);
44
+ const shake = useSharedValue(0);
45
+ const animationsActive = useWifiIconAnimationActive();
46
+
47
+ useEffect(() => {
48
+ enter.value = withTiming(1, {
49
+ duration: ENTER_DURATION,
50
+ easing: Easing.bezier(0.22, 1, 0.36, 1),
51
+ });
52
+ }, [enter]);
53
+
54
+ useEffect(() => {
55
+ if (!animationsActive) {
56
+ // Assigning a plain value cancels the running loop.
57
+ nudge.value = 0;
58
+ return;
59
+ }
60
+ // Starts once the wifi has broken, then repeats as a gentle reminder.
61
+ nudge.value = withDelay(
62
+ BREAK_TOTAL_MS,
63
+ withRepeat(
64
+ withSequence(
65
+ withTiming(1, {
66
+ duration: NUDGE_DURATION,
67
+ easing: Easing.bezier(0.22, 1, 0.36, 1),
68
+ }),
69
+ withTiming(0, { duration: 0 }),
70
+ withDelay(NUDGE_REST, withTiming(0, { duration: 0 })),
71
+ ),
72
+ -1,
73
+ false,
74
+ ),
75
+ );
76
+ }, [animationsActive, nudge]);
77
+
78
+ const enterStyle = useAnimatedStyle(() => ({
79
+ opacity: enter.value,
80
+ transform: [{ translateY: 16 * (1 - enter.value) }],
81
+ }));
82
+
83
+ // Bump up then settle during the first part of each nudge.
84
+ const retryStyle = useAnimatedStyle(() => {
85
+ const n = nudge.value;
86
+ const bump = n < 0.35 ? n / 0.35 : Math.max(0, 1 - (n - 0.35) / 0.35);
87
+ return {
88
+ transform: [{ translateX: shake.value }, { scale: 1 + 0.05 * bump }],
89
+ };
90
+ });
91
+
92
+ const haloStyle = useAnimatedStyle(() => {
93
+ const n = nudge.value;
94
+ return {
95
+ opacity: n > 0 ? 0.35 * (1 - n) : 0,
96
+ transform: [{ scaleX: 1 + 0.12 * n }, { scaleY: 1 + 0.45 * n }],
97
+ };
98
+ });
99
+
100
+ const shakeRetry = useCallback(() => {
101
+ shake.value = withSequence(
102
+ withTiming(-8, { duration: SHAKE_STEP }),
103
+ withTiming(8, { duration: SHAKE_STEP }),
104
+ withTiming(-6, { duration: SHAKE_STEP }),
105
+ withTiming(6, { duration: SHAKE_STEP }),
106
+ withTiming(0, { duration: SHAKE_STEP }),
107
+ );
108
+ Haptics.notification('error');
109
+ }, [shake]);
110
+
111
+ const handleRetry = useCallback(async () => {
112
+ setChecking(true);
113
+ try {
114
+ const online = await refresh();
115
+ if (!online) {
116
+ shakeRetry();
117
+ ErrorToast({
118
+ title: t('network.unavailableTitle'),
119
+ message: t('network.unavailableMessage'),
120
+ });
121
+ }
122
+ } finally {
123
+ setChecking(false);
124
+ }
125
+ }, [refresh, shakeRetry, t]);
126
+
127
+ return (
128
+ <View style={styles.container} accessibilityRole="summary">
129
+ <Animated.View style={[styles.content, enterStyle]}>
130
+ <View style={styles.illustration}>
131
+ <View style={styles.glow} />
132
+ <BrokenWifiAnimation size={ICON_SIZE} active={animationsActive} />
133
+ </View>
134
+
135
+ <CustomText
136
+ variant="title"
137
+ weight="700"
138
+ textColor={color.text_heading}
139
+ textAlign="center"
140
+ style={styles.title}
141
+ >
142
+ {title ?? t('network.unavailableTitle')}
143
+ </CustomText>
144
+
145
+ <CustomText
146
+ variant="label"
147
+ weight="400"
148
+ textColor={color.text_body}
149
+ textAlign="center"
150
+ style={styles.message}
151
+ >
152
+ {message ?? t('network.unavailableBody')}
153
+ </CustomText>
154
+
155
+ {showRetry ? (
156
+ <Animated.View style={retryStyle}>
157
+ <Animated.View
158
+ pointerEvents="none"
159
+ style={[styles.retryHalo, haloStyle]}
160
+ />
161
+ <CustomButton
162
+ title={t('common.retry')}
163
+ onPress={handleRetry}
164
+ loading={checking}
165
+ borderRadius={999}
166
+ containerStyle={styles.retryButton}
167
+ textStyle={styles.retryText}
168
+ />
169
+ </Animated.View>
170
+ ) : null}
171
+ </Animated.View>
172
+ </View>
173
+ );
174
+ };
175
+
176
+ const ICON_SIZE = normalize(120);
177
+ const GLOW_SIZE = normalize(150);
178
+
179
+ const makeStyles = (color: ColorType) =>
180
+ StyleSheet.create({
181
+ container: {
182
+ flex: 1,
183
+ alignItems: 'center',
184
+ justifyContent: 'center',
185
+ paddingHorizontal: spacing.xl,
186
+ paddingBottom: normalize(80),
187
+ backgroundColor: color.background_primary,
188
+ },
189
+ content: {
190
+ alignItems: 'center',
191
+ },
192
+ illustration: {
193
+ width: GLOW_SIZE,
194
+ height: GLOW_SIZE,
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ marginBottom: spacing.md,
198
+ },
199
+ glow: {
200
+ position: 'absolute',
201
+ width: GLOW_SIZE,
202
+ height: GLOW_SIZE,
203
+ borderRadius: GLOW_SIZE / 2,
204
+ backgroundColor: color.primary_wash,
205
+ },
206
+ title: {
207
+ marginBottom: spacing.sm,
208
+ },
209
+ message: {
210
+ maxWidth: normalize(280),
211
+ lineHeight: normalize(21),
212
+ marginBottom: spacing.lg,
213
+ },
214
+ retryHalo: {
215
+ ...StyleSheet.absoluteFill,
216
+ borderRadius: 999,
217
+ backgroundColor: color.primary,
218
+ },
219
+ retryButton: {
220
+ minWidth: normalize(180),
221
+ minHeight: normalize(48),
222
+ },
223
+ /** The shared `button` variant uses lineHeight === fontSize, which clips descenders. */
224
+ retryText: {
225
+ lineHeight: normalize(20),
226
+ },
227
+ });
228
+
229
+ export default memo(OfflineState);
@@ -0,0 +1,6 @@
1
+ export { default as OfflineQueryBoundary } from './OfflineQueryBoundary';
2
+ export { default as OfflineState } from './OfflineState';
3
+ export type {
4
+ OfflineQueryBoundaryProps,
5
+ OfflineStateProps,
6
+ } from './types';
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export type OfflineStateProps = {
4
+ title?: string;
5
+ message?: string;
6
+ showRetry?: boolean;
7
+ };
8
+
9
+ export type OfflineQueryBoundaryProps = {
10
+ /** True when the screen already has data to show (fresh or cached). */
11
+ hasData: boolean;
12
+ /** Shown while there is no data and the device is online (e.g. a shimmer). */
13
+ loading?: ReactNode;
14
+ children: ReactNode;
15
+ };
@@ -0,0 +1,116 @@
1
+ import React, { memo, useEffect } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+ import Animated, {
4
+ Easing,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withDelay,
8
+ withRepeat,
9
+ withSequence,
10
+ withTiming,
11
+ } from 'react-native-reanimated';
12
+ import Svg, { G, Path } from 'react-native-svg';
13
+ import { useAppTheme } from '@/context/ThemeContext';
14
+ import { PIECES, SHADOW_OFFSET, SOLID_PIECES, VIEWBOX } from './geometry';
15
+ import WifiPiece from './WifiPiece';
16
+
17
+ const HOLD_WHOLE = 700;
18
+ const BREAK_DURATION = 650;
19
+ export const BREAK_TOTAL_MS = HOLD_WHOLE + BREAK_DURATION;
20
+
21
+ /** Pause between shiver bursts once the icon is broken. */
22
+ const SHIVER_REST = 1800;
23
+ const SHIVER_STEP = 70;
24
+
25
+ type Props = {
26
+ size: number;
27
+ /** Pauses the looping shiver (e.g. app in background or screen unfocused). */
28
+ active?: boolean;
29
+ };
30
+
31
+ const BrokenWifiAnimation = ({ size, active = true }: Props) => {
32
+ const { color } = useAppTheme();
33
+ const progress = useSharedValue(0);
34
+ const shiver = useSharedValue(0);
35
+ const scale = size / VIEWBOX;
36
+
37
+ useEffect(() => {
38
+ progress.value = withDelay(
39
+ HOLD_WHOLE,
40
+ withTiming(1, {
41
+ duration: BREAK_DURATION,
42
+ easing: Easing.bezier(0.16, 1, 0.3, 1),
43
+ }),
44
+ );
45
+ }, [progress]);
46
+
47
+ useEffect(() => {
48
+ if (!active) {
49
+ // Assigning a plain value cancels the running loop.
50
+ shiver.value = 0;
51
+ return;
52
+ }
53
+ shiver.value = withDelay(
54
+ BREAK_TOTAL_MS,
55
+ withRepeat(
56
+ withSequence(
57
+ withDelay(SHIVER_REST, withTiming(1, { duration: SHIVER_STEP })),
58
+ withTiming(-1, { duration: SHIVER_STEP * 1.5 }),
59
+ withTiming(0.7, { duration: SHIVER_STEP * 1.5 }),
60
+ withTiming(-0.4, { duration: SHIVER_STEP * 1.5 }),
61
+ withTiming(0, { duration: SHIVER_STEP }),
62
+ ),
63
+ -1,
64
+ false,
65
+ ),
66
+ );
67
+ }, [active, shiver]);
68
+
69
+ // Before the break, one static SVG draws the whole icon (all shadows under all faces,
70
+ // so no seams); the individual pieces take over the moment it starts breaking.
71
+ const wholeStyle = useAnimatedStyle(() => ({
72
+ opacity: progress.value > 0 ? 0 : 1,
73
+ }));
74
+ const piecesStyle = useAnimatedStyle(() => ({
75
+ opacity: progress.value > 0 ? 1 : 0,
76
+ }));
77
+
78
+ return (
79
+ <View style={{ width: size, height: size }} accessibilityElementsHidden>
80
+ <Animated.View
81
+ pointerEvents="none"
82
+ style={[StyleSheet.absoluteFill, wholeStyle]}
83
+ >
84
+ <Svg width={size} height={size} viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}>
85
+ <G transform={`translate(0 ${SHADOW_OFFSET})`}>
86
+ {SOLID_PIECES.map((piece, index) => (
87
+ <Path key={index} d={piece.d} fill={color.primary_deep} />
88
+ ))}
89
+ </G>
90
+ {SOLID_PIECES.map((piece, index) => (
91
+ <Path key={index} d={piece.d} fill={color.primary} />
92
+ ))}
93
+ </Svg>
94
+ </Animated.View>
95
+
96
+ <Animated.View
97
+ pointerEvents="none"
98
+ style={[StyleSheet.absoluteFill, piecesStyle]}
99
+ >
100
+ {PIECES.map((piece, index) => (
101
+ <WifiPiece
102
+ key={index}
103
+ piece={piece}
104
+ scale={scale}
105
+ fill={piece.isShard ? color.primary_accent : color.primary}
106
+ shade={color.primary_deep}
107
+ progress={progress}
108
+ shiver={shiver}
109
+ />
110
+ ))}
111
+ </Animated.View>
112
+ </View>
113
+ );
114
+ };
115
+
116
+ export default memo(BrokenWifiAnimation);
@@ -0,0 +1,80 @@
1
+ import React, { memo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import Animated, {
4
+ type SharedValue,
5
+ useAnimatedStyle,
6
+ } from 'react-native-reanimated';
7
+ import Svg, { G, Path } from 'react-native-svg';
8
+ import { type PieceDef, SHADOW_OFFSET } from './geometry';
9
+
10
+ /** Max shiver offset (viewBox units) and rotation (deg) for the most displaced pieces. */
11
+ const SHIVER_SHIFT = 1.4;
12
+ const SHIVER_ROTATE = 2.5;
13
+
14
+ type Props = {
15
+ piece: PieceDef;
16
+ scale: number;
17
+ fill: string;
18
+ shade: string;
19
+ progress: SharedValue<number>;
20
+ shiver: SharedValue<number>;
21
+ };
22
+
23
+ const WifiPiece = ({ piece, scale, fill, shade, progress, shiver }: Props) => {
24
+ const { d, box, origin, dx, dy, rot, strength, isShard } = piece;
25
+
26
+ const animatedStyle = useAnimatedStyle(() => {
27
+ const b = progress.value;
28
+ const s = shiver.value * strength * b;
29
+ return {
30
+ opacity: isShard ? b : 1,
31
+ transform: [
32
+ { translateX: (dx * b + s * SHIVER_SHIFT) * scale },
33
+ { translateY: (dy * b - s * SHIVER_SHIFT * 0.5) * scale },
34
+ { rotate: `${rot * b + s * SHIVER_ROTATE}deg` },
35
+ ],
36
+ };
37
+ });
38
+
39
+ return (
40
+ <Animated.View
41
+ pointerEvents="none"
42
+ renderToHardwareTextureAndroid
43
+ shouldRasterizeIOS
44
+ style={[
45
+ styles.piece,
46
+ {
47
+ left: box.x * scale,
48
+ top: box.y * scale,
49
+ width: box.w * scale,
50
+ height: box.h * scale,
51
+ transformOrigin: [
52
+ (origin[0] - box.x) * scale,
53
+ (origin[1] - box.y) * scale,
54
+ 0,
55
+ ],
56
+ },
57
+ animatedStyle,
58
+ ]}
59
+ >
60
+ <Svg
61
+ width={box.w * scale}
62
+ height={box.h * scale}
63
+ viewBox={`${box.x} ${box.y} ${box.w} ${box.h}`}
64
+ >
65
+ <G transform={`translate(0 ${SHADOW_OFFSET})`}>
66
+ <Path d={d} fill={shade} />
67
+ </G>
68
+ <Path d={d} fill={fill} />
69
+ </Svg>
70
+ </Animated.View>
71
+ );
72
+ };
73
+
74
+ const styles = StyleSheet.create({
75
+ piece: {
76
+ position: 'absolute',
77
+ },
78
+ });
79
+
80
+ export default memo(WifiPiece);
@@ -0,0 +1,163 @@
1
+ /** All geometry is in a 100×100 viewBox and scaled to the rendered size. */
2
+ export const VIEWBOX = 100;
3
+ export const SHADOW_OFFSET = 2.5;
4
+
5
+ const CX = 50;
6
+ const CY = 80;
7
+ const SPAN = 45;
8
+ const BOX_PADDING = 1;
9
+
10
+ type Point = [number, number];
11
+ type Edge = [number, number];
12
+ export type Box = { x: number; y: number; w: number; h: number };
13
+
14
+ export type PieceDef = {
15
+ d: string;
16
+ box: Box;
17
+ origin: Point;
18
+ dx: number;
19
+ dy: number;
20
+ rot: number;
21
+ /** Shiver amount, signed so neighbours move in opposite directions. */
22
+ strength: number;
23
+ isShard?: boolean;
24
+ };
25
+
26
+ const polar = (r: number, deg: number): Point => {
27
+ const rad = (deg * Math.PI) / 180;
28
+ return [CX + r * Math.sin(rad), CY - r * Math.cos(rad)];
29
+ };
30
+
31
+ const arcPoints = (r: number, from: number, to: number): Point[] => {
32
+ const steps = Math.max(2, Math.ceil(Math.abs(to - from) / 5));
33
+ return Array.from({ length: steps + 1 }, (_, i) =>
34
+ polar(r, from + ((to - from) * i) / steps),
35
+ );
36
+ };
37
+
38
+ /** Tight bounds (plus shadow) so each piece rasterises only its own area, not the full canvas. */
39
+ const boundsOf = (points: Point[]): Box => {
40
+ const xs = points.map(p => p[0]);
41
+ const ys = points.map(p => p[1]);
42
+ const x = Math.min(...xs) - BOX_PADDING;
43
+ const y = Math.min(...ys) - BOX_PADDING;
44
+ return {
45
+ x,
46
+ y,
47
+ w: Math.max(...xs) + BOX_PADDING - x,
48
+ h: Math.max(...ys) + BOX_PADDING + SHADOW_OFFSET - y,
49
+ };
50
+ };
51
+
52
+ /** Thick arc segment; separate inner/outer angles give the cut edges a jagged slant. */
53
+ const band = (r1: number, r2: number, outer: Edge, inner: Edge) => {
54
+ const [ox1, oy1] = polar(r2, outer[0]);
55
+ const [ox2, oy2] = polar(r2, outer[1]);
56
+ const [ix2, iy2] = polar(r1, inner[1]);
57
+ const [ix1, iy1] = polar(r1, inner[0]);
58
+ return {
59
+ d: `M${ox1} ${oy1} A${r2} ${r2} 0 0 1 ${ox2} ${oy2} L${ix2} ${iy2} A${r1} ${r1} 0 0 0 ${ix1} ${iy1} Z`,
60
+ box: boundsOf([
61
+ ...arcPoints(r2, outer[0], outer[1]),
62
+ ...arcPoints(r1, inner[0], inner[1]),
63
+ ]),
64
+ };
65
+ };
66
+
67
+ const shard = ([x, y]: Point, s: number) => {
68
+ const points: Point[] = [
69
+ [x, y - s],
70
+ [x + s, y + s * 0.6],
71
+ [x - s * 0.8, y + s * 0.4],
72
+ ];
73
+ return {
74
+ d: `M${points[0][0]} ${points[0][1]} L${points[1][0]} ${points[1][1]} L${points[2][0]} ${points[2][1]} Z`,
75
+ box: boundsOf(points),
76
+ };
77
+ };
78
+
79
+ const BANDS: { r1: number; r2: number }[] = [
80
+ { r1: 16, r2: 28 },
81
+ { r1: 36, r2: 48 },
82
+ { r1: 56, r2: 68 },
83
+ ];
84
+
85
+ const CUT_ONE = { outer: -6, inner: 2 };
86
+ const CUT_TWO = { outer: 22, inner: 14 };
87
+
88
+ type RawPiece = Omit<PieceDef, 'strength'>;
89
+
90
+ const RAW_PIECES: RawPiece[] = [
91
+ {
92
+ d: `M${CX - 8} ${CY} a8 8 0 1 0 16 0 a8 8 0 1 0 -16 0`,
93
+ box: boundsOf([
94
+ [CX - 8, CY - 8],
95
+ [CX + 8, CY + 8],
96
+ ]),
97
+ origin: [CX, CY],
98
+ dx: 0,
99
+ dy: 2,
100
+ rot: 0,
101
+ },
102
+ ...BANDS.flatMap(({ r1, r2 }, index): RawPiece[] => {
103
+ const midR = (r1 + r2) / 2;
104
+ const reach = index + 1;
105
+ return [
106
+ {
107
+ ...band(r1, r2, [-SPAN, CUT_ONE.outer], [-SPAN, CUT_ONE.inner]),
108
+ origin: polar(midR, -25),
109
+ dx: -1,
110
+ dy: 1,
111
+ rot: -3,
112
+ },
113
+ {
114
+ ...band(
115
+ r1,
116
+ r2,
117
+ [CUT_ONE.outer, CUT_TWO.outer],
118
+ [CUT_ONE.inner, CUT_TWO.inner],
119
+ ),
120
+ origin: polar(midR, 8),
121
+ dx: 1.5 + reach * 1.5,
122
+ dy: -2 - reach * 1.5,
123
+ rot: 6 + reach * 3,
124
+ },
125
+ {
126
+ ...band(r1, r2, [CUT_TWO.outer, SPAN], [CUT_TWO.inner, SPAN]),
127
+ origin: polar(midR, 33),
128
+ dx: 4 + reach * 2.5,
129
+ dy: -1 - reach * 2.5,
130
+ rot: 12 + reach * 5,
131
+ },
132
+ ];
133
+ }),
134
+ ...(
135
+ [
136
+ [[58, 18], 3.5, 8, -16, 120],
137
+ [[74, 24], 3, 16, -10, -140],
138
+ [[66, 38], 3.5, 14, -6, 160],
139
+ [[55, 44], 2.5, 6, -14, -100],
140
+ [[64, 56], 3, 12, -4, 110],
141
+ [[80, 12], 2.5, 14, -16, 180],
142
+ [[52, 28], 2.5, 4, -18, -160],
143
+ ] as [Point, number, number, number, number][]
144
+ ).map(
145
+ ([origin, s, dx, dy, rot]): RawPiece => ({
146
+ ...shard(origin, s),
147
+ origin,
148
+ dx,
149
+ dy,
150
+ rot,
151
+ isShard: true,
152
+ }),
153
+ ),
154
+ ];
155
+
156
+ // Pieces that flew further shiver more; the anchored left halves barely move.
157
+ export const PIECES: PieceDef[] = RAW_PIECES.map((piece, index) => ({
158
+ ...piece,
159
+ strength:
160
+ Math.min(1, Math.hypot(piece.dx, piece.dy) / 10) * (index % 2 ? -1 : 1),
161
+ }));
162
+
163
+ export const SOLID_PIECES = PIECES.filter(piece => !piece.isShard);
@@ -0,0 +1,37 @@
1
+ import { useContext, useEffect, useState } from 'react';
2
+ import { AppState } from 'react-native';
3
+ import { NavigationContext } from '@react-navigation/native';
4
+
5
+ /**
6
+ * False while the app is backgrounded or the hosting screen is unfocused (e.g. another
7
+ * tab), so looping animations can stop instead of burning frames off-screen.
8
+ * Reads the navigation context directly so it also works outside a navigator.
9
+ */
10
+ export const useWifiIconAnimationActive = () => {
11
+ const navigation = useContext(NavigationContext);
12
+ const [focused, setFocused] = useState(() => navigation?.isFocused() ?? true);
13
+ const [foreground, setForeground] = useState(
14
+ AppState.currentState !== 'background',
15
+ );
16
+
17
+ useEffect(() => {
18
+ if (!navigation) {
19
+ return;
20
+ }
21
+ const offFocus = navigation.addListener('focus', () => setFocused(true));
22
+ const offBlur = navigation.addListener('blur', () => setFocused(false));
23
+ return () => {
24
+ offFocus();
25
+ offBlur();
26
+ };
27
+ }, [navigation]);
28
+
29
+ useEffect(() => {
30
+ const subscription = AppState.addEventListener('change', state =>
31
+ setForeground(state === 'active'),
32
+ );
33
+ return () => subscription.remove();
34
+ }, []);
35
+
36
+ return focused && foreground;
37
+ };