@croacroa/react-native-template 2.1.0 → 3.2.0

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 (172) hide show
  1. package/.env.example +5 -0
  2. package/.eslintrc.js +8 -0
  3. package/.github/workflows/ci.yml +187 -187
  4. package/.github/workflows/eas-build.yml +55 -55
  5. package/.github/workflows/eas-update.yml +50 -50
  6. package/.github/workflows/npm-publish.yml +57 -0
  7. package/CHANGELOG.md +195 -106
  8. package/CONTRIBUTING.md +377 -377
  9. package/LICENSE +21 -21
  10. package/README.md +446 -402
  11. package/__tests__/accessibility/components.test.tsx +285 -0
  12. package/__tests__/components/Button.test.tsx +2 -4
  13. package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
  14. package/__tests__/components/snapshots.test.tsx +131 -131
  15. package/__tests__/helpers/a11y.ts +54 -0
  16. package/__tests__/hooks/useAnalytics.test.ts +100 -0
  17. package/__tests__/hooks/useAnimations.test.ts +70 -0
  18. package/__tests__/hooks/useAuth.test.tsx +71 -28
  19. package/__tests__/hooks/useMedia.test.ts +318 -0
  20. package/__tests__/hooks/usePayments.test.tsx +307 -0
  21. package/__tests__/hooks/usePermission.test.ts +230 -0
  22. package/__tests__/hooks/useWebSocket.test.ts +329 -0
  23. package/__tests__/integration/auth-api.test.tsx +224 -227
  24. package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
  25. package/__tests__/services/api.test.ts +24 -6
  26. package/app/(auth)/home.tsx +11 -9
  27. package/app/(auth)/profile.tsx +8 -6
  28. package/app/(auth)/settings.tsx +11 -9
  29. package/app/(public)/forgot-password.tsx +25 -15
  30. package/app/(public)/login.tsx +48 -12
  31. package/app/(public)/onboarding.tsx +5 -5
  32. package/app/(public)/register.tsx +24 -15
  33. package/app/_layout.tsx +6 -3
  34. package/app.config.ts +27 -2
  35. package/assets/images/.gitkeep +7 -7
  36. package/assets/images/adaptive-icon.png +0 -0
  37. package/assets/images/favicon.png +0 -0
  38. package/assets/images/icon.png +0 -0
  39. package/assets/images/notification-icon.png +0 -0
  40. package/assets/images/splash.png +0 -0
  41. package/components/ErrorBoundary.tsx +73 -28
  42. package/components/auth/SocialLoginButtons.tsx +168 -0
  43. package/components/forms/FormInput.tsx +5 -3
  44. package/components/onboarding/OnboardingScreen.tsx +370 -370
  45. package/components/onboarding/index.ts +2 -2
  46. package/components/providers/AnalyticsProvider.tsx +67 -0
  47. package/components/providers/SuspenseBoundary.tsx +359 -357
  48. package/components/providers/index.ts +24 -21
  49. package/components/ui/AnimatedButton.tsx +1 -9
  50. package/components/ui/AnimatedList.tsx +98 -0
  51. package/components/ui/AnimatedScreen.tsx +89 -0
  52. package/components/ui/Avatar.tsx +319 -316
  53. package/components/ui/Badge.tsx +416 -416
  54. package/components/ui/BottomSheet.tsx +307 -307
  55. package/components/ui/Button.tsx +11 -3
  56. package/components/ui/Checkbox.tsx +261 -261
  57. package/components/ui/FeatureGate.tsx +57 -0
  58. package/components/ui/ForceUpdateScreen.tsx +108 -0
  59. package/components/ui/ImagePickerButton.tsx +180 -0
  60. package/components/ui/Input.stories.tsx +2 -10
  61. package/components/ui/Input.tsx +2 -10
  62. package/components/ui/OptimizedImage.tsx +369 -369
  63. package/components/ui/Paywall.tsx +253 -0
  64. package/components/ui/PermissionGate.tsx +155 -0
  65. package/components/ui/PurchaseButton.tsx +84 -0
  66. package/components/ui/Select.tsx +240 -240
  67. package/components/ui/Skeleton.tsx +3 -1
  68. package/components/ui/Toast.tsx +427 -418
  69. package/components/ui/UploadProgress.tsx +189 -0
  70. package/components/ui/VirtualizedList.tsx +288 -285
  71. package/components/ui/index.ts +28 -30
  72. package/constants/config.ts +135 -97
  73. package/docs/adr/001-state-management.md +79 -79
  74. package/docs/adr/002-styling-approach.md +130 -130
  75. package/docs/adr/003-data-fetching.md +155 -155
  76. package/docs/adr/004-auth-adapter-pattern.md +144 -144
  77. package/docs/adr/README.md +78 -78
  78. package/docs/guides/analytics-posthog.md +121 -0
  79. package/docs/guides/auth-supabase.md +162 -0
  80. package/docs/guides/feature-flags-launchdarkly.md +150 -0
  81. package/docs/guides/payments-revenuecat.md +169 -0
  82. package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
  83. package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
  84. package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
  85. package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
  86. package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
  87. package/eas.json +2 -1
  88. package/hooks/index.ts +70 -40
  89. package/hooks/useAnimatedEntry.ts +204 -0
  90. package/hooks/useApi.ts +5 -4
  91. package/hooks/useAuth.tsx +7 -3
  92. package/hooks/useBiometrics.ts +295 -295
  93. package/hooks/useChannel.ts +111 -0
  94. package/hooks/useDeepLinking.ts +256 -256
  95. package/hooks/useExperiment.ts +36 -0
  96. package/hooks/useFeatureFlag.ts +59 -0
  97. package/hooks/useForceUpdate.ts +91 -0
  98. package/hooks/useImagePicker.ts +281 -375
  99. package/hooks/useInAppReview.ts +64 -0
  100. package/hooks/useMFA.ts +509 -499
  101. package/hooks/useParallax.ts +142 -0
  102. package/hooks/usePerformance.ts +434 -434
  103. package/hooks/usePermission.ts +190 -0
  104. package/hooks/usePresence.ts +129 -0
  105. package/hooks/useProducts.ts +36 -0
  106. package/hooks/usePurchase.ts +103 -0
  107. package/hooks/useRateLimit.ts +70 -0
  108. package/hooks/useSubscription.ts +49 -0
  109. package/hooks/useTrackEvent.ts +52 -0
  110. package/hooks/useTrackScreen.ts +40 -0
  111. package/hooks/useUpdates.ts +358 -358
  112. package/hooks/useUpload.ts +165 -0
  113. package/hooks/useWebSocket.ts +111 -0
  114. package/i18n/index.ts +197 -194
  115. package/i18n/locales/ar.json +170 -101
  116. package/i18n/locales/de.json +170 -101
  117. package/i18n/locales/en.json +170 -101
  118. package/i18n/locales/es.json +170 -101
  119. package/i18n/locales/fr.json +170 -101
  120. package/jest.config.js +1 -1
  121. package/maestro/README.md +113 -113
  122. package/maestro/config.yaml +35 -35
  123. package/maestro/flows/login.yaml +62 -62
  124. package/maestro/flows/mfa-login.yaml +92 -92
  125. package/maestro/flows/mfa-setup.yaml +86 -86
  126. package/maestro/flows/navigation.yaml +68 -68
  127. package/maestro/flows/offline-conflict.yaml +101 -101
  128. package/maestro/flows/offline-sync.yaml +128 -128
  129. package/maestro/flows/offline.yaml +60 -60
  130. package/maestro/flows/register.yaml +94 -94
  131. package/package.json +188 -176
  132. package/scripts/generate-placeholders.js +38 -0
  133. package/services/analytics/adapters/console.ts +50 -0
  134. package/services/analytics/analytics-adapter.ts +94 -0
  135. package/services/analytics/types.ts +73 -0
  136. package/services/analytics.ts +428 -428
  137. package/services/api.ts +419 -340
  138. package/services/auth/social/apple.ts +110 -0
  139. package/services/auth/social/google.ts +159 -0
  140. package/services/auth/social/social-auth.ts +100 -0
  141. package/services/auth/social/types.ts +80 -0
  142. package/services/authAdapter.ts +333 -333
  143. package/services/backgroundSync.ts +652 -626
  144. package/services/feature-flags/adapters/mock.ts +108 -0
  145. package/services/feature-flags/feature-flag-adapter.ts +174 -0
  146. package/services/feature-flags/types.ts +79 -0
  147. package/services/force-update.ts +140 -0
  148. package/services/index.ts +116 -54
  149. package/services/media/compression.ts +91 -0
  150. package/services/media/media-picker.ts +151 -0
  151. package/services/media/media-upload.ts +160 -0
  152. package/services/payments/adapters/mock.ts +159 -0
  153. package/services/payments/payment-adapter.ts +118 -0
  154. package/services/payments/types.ts +131 -0
  155. package/services/permissions/permission-manager.ts +284 -0
  156. package/services/permissions/types.ts +104 -0
  157. package/services/realtime/types.ts +100 -0
  158. package/services/realtime/websocket-manager.ts +441 -0
  159. package/services/security.ts +289 -286
  160. package/services/sentry.ts +4 -4
  161. package/stores/appStore.ts +9 -0
  162. package/stores/notificationStore.ts +3 -1
  163. package/tailwind.config.js +47 -47
  164. package/tsconfig.json +37 -13
  165. package/types/user.ts +1 -1
  166. package/utils/accessibility.ts +446 -446
  167. package/utils/animations/presets.ts +182 -0
  168. package/utils/animations/transitions.ts +62 -0
  169. package/utils/index.ts +63 -52
  170. package/utils/toast.ts +9 -2
  171. package/utils/validation.ts +4 -1
  172. package/utils/withAccessibility.tsx +272 -272
@@ -1,418 +1,427 @@
1
- /**
2
- * @fileoverview Custom Toast component with animations
3
- * Provides a flexible toast notification system as an alternative to Burnt.
4
- * @module components/ui/Toast
5
- */
6
-
7
- import React, {
8
- createContext,
9
- useContext,
10
- useState,
11
- useCallback,
12
- useRef,
13
- useEffect,
14
- ReactNode,
15
- } from "react";
16
- import {
17
- View,
18
- Text,
19
- Pressable,
20
- StyleSheet,
21
- Dimensions,
22
- AccessibilityInfo,
23
- } from "react-native";
24
- import Animated, {
25
- useAnimatedStyle,
26
- useSharedValue,
27
- withSpring,
28
- withTiming,
29
- runOnJS,
30
- SlideInUp,
31
- SlideOutUp,
32
- } from "react-native-reanimated";
33
- import { Ionicons } from "@expo/vector-icons";
34
- import { useSafeAreaInsets } from "react-native-safe-area-context";
35
-
36
- // ============================================================================
37
- // Types
38
- // ============================================================================
39
-
40
- export type ToastType = "success" | "error" | "warning" | "info";
41
-
42
- export interface ToastConfig {
43
- /** Unique identifier */
44
- id: string;
45
- /** Toast type determines icon and colors */
46
- type: ToastType;
47
- /** Main message */
48
- title: string;
49
- /** Optional description */
50
- message?: string;
51
- /** Duration in ms (0 = persistent) */
52
- duration?: number;
53
- /** Action button */
54
- action?: {
55
- label: string;
56
- onPress: () => void;
57
- };
58
- /** Called when toast is dismissed */
59
- onDismiss?: () => void;
60
- }
61
-
62
- interface ToastContextValue {
63
- show: (config: Omit<ToastConfig, "id">) => string;
64
- success: (title: string, message?: string) => string;
65
- error: (title: string, message?: string) => string;
66
- warning: (title: string, message?: string) => string;
67
- info: (title: string, message?: string) => string;
68
- dismiss: (id: string) => void;
69
- dismissAll: () => void;
70
- }
71
-
72
- // ============================================================================
73
- // Context
74
- // ============================================================================
75
-
76
- const ToastContext = createContext<ToastContextValue | null>(null);
77
-
78
- /**
79
- * Hook to access toast functions
80
- *
81
- * @example
82
- * ```tsx
83
- * function MyComponent() {
84
- * const toast = useToast();
85
- *
86
- * const handleSave = async () => {
87
- * try {
88
- * await saveData();
89
- * toast.success("Saved!", "Your changes have been saved.");
90
- * } catch (e) {
91
- * toast.error("Error", "Failed to save changes.");
92
- * }
93
- * };
94
- * }
95
- * ```
96
- */
97
- export function useToast(): ToastContextValue {
98
- const context = useContext(ToastContext);
99
- if (!context) {
100
- throw new Error("useToast must be used within a ToastProvider");
101
- }
102
- return context;
103
- }
104
-
105
- // ============================================================================
106
- // Toast Item Component
107
- // ============================================================================
108
-
109
- const TOAST_CONFIG = {
110
- success: {
111
- icon: "checkmark-circle" as const,
112
- bgColor: "bg-green-500",
113
- iconColor: "#22c55e",
114
- },
115
- error: {
116
- icon: "close-circle" as const,
117
- bgColor: "bg-red-500",
118
- iconColor: "#ef4444",
119
- },
120
- warning: {
121
- icon: "warning" as const,
122
- bgColor: "bg-amber-500",
123
- iconColor: "#f59e0b",
124
- },
125
- info: {
126
- icon: "information-circle" as const,
127
- bgColor: "bg-blue-500",
128
- iconColor: "#3b82f6",
129
- },
130
- };
131
-
132
- interface ToastItemProps {
133
- config: ToastConfig;
134
- onDismiss: (id: string) => void;
135
- }
136
-
137
- function ToastItem({ config, onDismiss }: ToastItemProps) {
138
- const { type, title, message, duration = 4000, action, id } = config;
139
- const { icon, iconColor } = TOAST_CONFIG[type];
140
- const progress = useSharedValue(1);
141
- const timerRef = useRef<NodeJS.Timeout>();
142
-
143
- useEffect(() => {
144
- // Announce to screen readers
145
- AccessibilityInfo.announceForAccessibility(`${type}: ${title}. ${message || ""}`);
146
-
147
- if (duration > 0) {
148
- // Start progress animation
149
- progress.value = withTiming(0, { duration });
150
-
151
- // Auto-dismiss timer
152
- timerRef.current = setTimeout(() => {
153
- onDismiss(id);
154
- }, duration);
155
- }
156
-
157
- return () => {
158
- if (timerRef.current) {
159
- clearTimeout(timerRef.current);
160
- }
161
- };
162
- }, [duration, id, onDismiss, progress, type, title, message]);
163
-
164
- const progressStyle = useAnimatedStyle(() => ({
165
- width: `${progress.value * 100}%`,
166
- }));
167
-
168
- const handleDismiss = () => {
169
- if (timerRef.current) {
170
- clearTimeout(timerRef.current);
171
- }
172
- config.onDismiss?.();
173
- onDismiss(id);
174
- };
175
-
176
- return (
177
- <Animated.View
178
- entering={SlideInUp.springify().damping(15)}
179
- exiting={SlideOutUp.springify().damping(15)}
180
- style={styles.toastContainer}
181
- accessibilityRole="alert"
182
- accessibilityLiveRegion="polite"
183
- >
184
- <Pressable
185
- onPress={handleDismiss}
186
- style={styles.toastContent}
187
- className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden"
188
- >
189
- {/* Icon */}
190
- <View
191
- className="w-10 h-10 rounded-full items-center justify-center mr-3"
192
- style={{ backgroundColor: `${iconColor}20` }}
193
- >
194
- <Ionicons name={icon} size={24} color={iconColor} />
195
- </View>
196
-
197
- {/* Text */}
198
- <View style={styles.textContainer}>
199
- <Text
200
- className="text-base font-semibold text-gray-900 dark:text-white"
201
- numberOfLines={1}
202
- >
203
- {title}
204
- </Text>
205
- {message && (
206
- <Text
207
- className="text-sm text-gray-600 dark:text-gray-300 mt-0.5"
208
- numberOfLines={2}
209
- >
210
- {message}
211
- </Text>
212
- )}
213
- </View>
214
-
215
- {/* Action Button */}
216
- {action && (
217
- <Pressable
218
- onPress={() => {
219
- action.onPress();
220
- handleDismiss();
221
- }}
222
- className="ml-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-lg"
223
- >
224
- <Text className="text-sm font-medium text-primary-600 dark:text-primary-400">
225
- {action.label}
226
- </Text>
227
- </Pressable>
228
- )}
229
-
230
- {/* Close button */}
231
- <Pressable
232
- onPress={handleDismiss}
233
- className="ml-2 p-1"
234
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
235
- accessibilityLabel="Dismiss notification"
236
- accessibilityRole="button"
237
- >
238
- <Ionicons name="close" size={20} color="#9ca3af" />
239
- </Pressable>
240
- </Pressable>
241
-
242
- {/* Progress bar */}
243
- {duration > 0 && (
244
- <View className="absolute bottom-0 left-4 right-4 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
245
- <Animated.View
246
- style={[styles.progressBar, progressStyle, { backgroundColor: iconColor }]}
247
- />
248
- </View>
249
- )}
250
- </Animated.View>
251
- );
252
- }
253
-
254
- // ============================================================================
255
- // Toast Provider
256
- // ============================================================================
257
-
258
- interface ToastProviderProps {
259
- children: ReactNode;
260
- /** Maximum number of toasts to show at once */
261
- maxToasts?: number;
262
- }
263
-
264
- /**
265
- * Toast provider component. Wrap your app with this to enable toasts.
266
- *
267
- * @example
268
- * ```tsx
269
- * export default function App() {
270
- * return (
271
- * <ToastProvider maxToasts={3}>
272
- * <NavigationContainer>
273
- * <RootNavigator />
274
- * </NavigationContainer>
275
- * </ToastProvider>
276
- * );
277
- * }
278
- * ```
279
- */
280
- export function ToastProvider({ children, maxToasts = 3 }: ToastProviderProps) {
281
- const [toasts, setToasts] = useState<ToastConfig[]>([]);
282
- const insets = useSafeAreaInsets();
283
- const idCounter = useRef(0);
284
-
285
- const dismiss = useCallback((id: string) => {
286
- setToasts((prev) => prev.filter((t) => t.id !== id));
287
- }, []);
288
-
289
- const dismissAll = useCallback(() => {
290
- setToasts([]);
291
- }, []);
292
-
293
- const show = useCallback(
294
- (config: Omit<ToastConfig, "id">): string => {
295
- const id = `toast-${++idCounter.current}`;
296
- const newToast: ToastConfig = { ...config, id };
297
-
298
- setToasts((prev) => {
299
- const updated = [newToast, ...prev];
300
- // Limit number of toasts
301
- return updated.slice(0, maxToasts);
302
- });
303
-
304
- return id;
305
- },
306
- [maxToasts]
307
- );
308
-
309
- const success = useCallback(
310
- (title: string, message?: string) => show({ type: "success", title, message }),
311
- [show]
312
- );
313
-
314
- const error = useCallback(
315
- (title: string, message?: string) => show({ type: "error", title, message }),
316
- [show]
317
- );
318
-
319
- const warning = useCallback(
320
- (title: string, message?: string) => show({ type: "warning", title, message }),
321
- [show]
322
- );
323
-
324
- const info = useCallback(
325
- (title: string, message?: string) => show({ type: "info", title, message }),
326
- [show]
327
- );
328
-
329
- return (
330
- <ToastContext.Provider
331
- value={{ show, success, error, warning, info, dismiss, dismissAll }}
332
- >
333
- {children}
334
- <View
335
- style={[styles.container, { top: insets.top + 8 }]}
336
- pointerEvents="box-none"
337
- >
338
- {toasts.map((toast) => (
339
- <ToastItem key={toast.id} config={toast} onDismiss={dismiss} />
340
- ))}
341
- </View>
342
- </ToastContext.Provider>
343
- );
344
- }
345
-
346
- // ============================================================================
347
- // Styles
348
- // ============================================================================
349
-
350
- const { width } = Dimensions.get("window");
351
-
352
- const styles = StyleSheet.create({
353
- container: {
354
- position: "absolute",
355
- left: 16,
356
- right: 16,
357
- zIndex: 9999,
358
- alignItems: "center",
359
- },
360
- toastContainer: {
361
- width: width - 32,
362
- marginBottom: 8,
363
- },
364
- toastContent: {
365
- flexDirection: "row",
366
- alignItems: "center",
367
- padding: 12,
368
- paddingBottom: 16,
369
- },
370
- textContainer: {
371
- flex: 1,
372
- },
373
- progressBar: {
374
- height: "100%",
375
- borderRadius: 2,
376
- },
377
- });
378
-
379
- // ============================================================================
380
- // Imperative API (for use outside React components)
381
- // ============================================================================
382
-
383
- let toastRef: ToastContextValue | null = null;
384
-
385
- export function setToastRef(ref: ToastContextValue | null) {
386
- toastRef = ref;
387
- }
388
-
389
- /**
390
- * Imperative toast API for use outside React components.
391
- * Must call setToastRef first from within ToastProvider.
392
- *
393
- * @example
394
- * ```tsx
395
- * // In your root component:
396
- * function App() {
397
- * const toastContext = useToast();
398
- * useEffect(() => {
399
- * setToastRef(toastContext);
400
- * return () => setToastRef(null);
401
- * }, [toastContext]);
402
- * // ...
403
- * }
404
- *
405
- * // Then anywhere:
406
- * import { toastManager } from '@/components/ui/Toast';
407
- * toastManager.success('Done!');
408
- * ```
409
- */
410
- export const toastManager = {
411
- show: (config: Omit<ToastConfig, "id">) => toastRef?.show(config),
412
- success: (title: string, message?: string) => toastRef?.success(title, message),
413
- error: (title: string, message?: string) => toastRef?.error(title, message),
414
- warning: (title: string, message?: string) => toastRef?.warning(title, message),
415
- info: (title: string, message?: string) => toastRef?.info(title, message),
416
- dismiss: (id: string) => toastRef?.dismiss(id),
417
- dismissAll: () => toastRef?.dismissAll(),
418
- };
1
+ /**
2
+ * @fileoverview Custom Toast component with animations
3
+ * Provides a flexible toast notification system as an alternative to Burnt.
4
+ * @module components/ui/Toast
5
+ */
6
+
7
+ import React, {
8
+ createContext,
9
+ useContext,
10
+ useState,
11
+ useCallback,
12
+ useRef,
13
+ useEffect,
14
+ ReactNode,
15
+ } from "react";
16
+ import {
17
+ View,
18
+ Text,
19
+ Pressable,
20
+ StyleSheet,
21
+ Dimensions,
22
+ AccessibilityInfo,
23
+ } from "react-native";
24
+ import Animated, {
25
+ useAnimatedStyle,
26
+ useSharedValue,
27
+ withTiming,
28
+ SlideInUp,
29
+ SlideOutUp,
30
+ } from "react-native-reanimated";
31
+ import { Ionicons } from "@expo/vector-icons";
32
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
33
+
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+
38
+ export type ToastType = "success" | "error" | "warning" | "info";
39
+
40
+ export interface ToastConfig {
41
+ /** Unique identifier */
42
+ id: string;
43
+ /** Toast type determines icon and colors */
44
+ type: ToastType;
45
+ /** Main message */
46
+ title: string;
47
+ /** Optional description */
48
+ message?: string;
49
+ /** Duration in ms (0 = persistent) */
50
+ duration?: number;
51
+ /** Action button */
52
+ action?: {
53
+ label: string;
54
+ onPress: () => void;
55
+ };
56
+ /** Called when toast is dismissed */
57
+ onDismiss?: () => void;
58
+ }
59
+
60
+ interface ToastContextValue {
61
+ show: (config: Omit<ToastConfig, "id">) => string;
62
+ success: (title: string, message?: string) => string;
63
+ error: (title: string, message?: string) => string;
64
+ warning: (title: string, message?: string) => string;
65
+ info: (title: string, message?: string) => string;
66
+ dismiss: (id: string) => void;
67
+ dismissAll: () => void;
68
+ }
69
+
70
+ // ============================================================================
71
+ // Context
72
+ // ============================================================================
73
+
74
+ const ToastContext = createContext<ToastContextValue | null>(null);
75
+
76
+ /**
77
+ * Hook to access toast functions
78
+ *
79
+ * @example
80
+ * ```tsx
81
+ * function MyComponent() {
82
+ * const toast = useToast();
83
+ *
84
+ * const handleSave = async () => {
85
+ * try {
86
+ * await saveData();
87
+ * toast.success("Saved!", "Your changes have been saved.");
88
+ * } catch (e) {
89
+ * toast.error("Error", "Failed to save changes.");
90
+ * }
91
+ * };
92
+ * }
93
+ * ```
94
+ */
95
+ export function useToast(): ToastContextValue {
96
+ const context = useContext(ToastContext);
97
+ if (!context) {
98
+ throw new Error("useToast must be used within a ToastProvider");
99
+ }
100
+ return context;
101
+ }
102
+
103
+ // ============================================================================
104
+ // Toast Item Component
105
+ // ============================================================================
106
+
107
+ const TOAST_CONFIG = {
108
+ success: {
109
+ icon: "checkmark-circle" as const,
110
+ bgColor: "bg-green-500",
111
+ iconColor: "#22c55e",
112
+ },
113
+ error: {
114
+ icon: "close-circle" as const,
115
+ bgColor: "bg-red-500",
116
+ iconColor: "#ef4444",
117
+ },
118
+ warning: {
119
+ icon: "warning" as const,
120
+ bgColor: "bg-amber-500",
121
+ iconColor: "#f59e0b",
122
+ },
123
+ info: {
124
+ icon: "information-circle" as const,
125
+ bgColor: "bg-blue-500",
126
+ iconColor: "#3b82f6",
127
+ },
128
+ };
129
+
130
+ interface ToastItemProps {
131
+ config: ToastConfig;
132
+ onDismiss: (id: string) => void;
133
+ }
134
+
135
+ function ToastItem({ config, onDismiss }: ToastItemProps) {
136
+ const { type, title, message, duration = 4000, action, id } = config;
137
+ const { icon, iconColor } = TOAST_CONFIG[type];
138
+ const progress = useSharedValue(1);
139
+ const timerRef = useRef<NodeJS.Timeout>();
140
+
141
+ useEffect(() => {
142
+ // Announce to screen readers
143
+ AccessibilityInfo.announceForAccessibility(
144
+ `${type}: ${title}. ${message || ""}`
145
+ );
146
+
147
+ if (duration > 0) {
148
+ // Start progress animation
149
+ progress.value = withTiming(0, { duration });
150
+
151
+ // Auto-dismiss timer
152
+ timerRef.current = setTimeout(() => {
153
+ onDismiss(id);
154
+ }, duration);
155
+ }
156
+
157
+ return () => {
158
+ if (timerRef.current) {
159
+ clearTimeout(timerRef.current);
160
+ }
161
+ };
162
+ }, [duration, id, onDismiss, progress, type, title, message]);
163
+
164
+ const progressStyle = useAnimatedStyle(() => ({
165
+ width: `${progress.value * 100}%`,
166
+ }));
167
+
168
+ const handleDismiss = () => {
169
+ if (timerRef.current) {
170
+ clearTimeout(timerRef.current);
171
+ }
172
+ config.onDismiss?.();
173
+ onDismiss(id);
174
+ };
175
+
176
+ return (
177
+ <Animated.View
178
+ entering={SlideInUp.springify().damping(15)}
179
+ exiting={SlideOutUp.springify().damping(15)}
180
+ style={styles.toastContainer}
181
+ accessibilityRole="alert"
182
+ accessibilityLiveRegion="polite"
183
+ >
184
+ <Pressable
185
+ onPress={handleDismiss}
186
+ style={styles.toastContent}
187
+ className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg overflow-hidden"
188
+ >
189
+ {/* Icon */}
190
+ <View
191
+ className="w-10 h-10 rounded-full items-center justify-center mr-3"
192
+ style={{ backgroundColor: `${iconColor}20` }}
193
+ >
194
+ <Ionicons name={icon} size={24} color={iconColor} />
195
+ </View>
196
+
197
+ {/* Text */}
198
+ <View style={styles.textContainer}>
199
+ <Text
200
+ className="text-base font-semibold text-gray-900 dark:text-white"
201
+ numberOfLines={1}
202
+ >
203
+ {title}
204
+ </Text>
205
+ {message && (
206
+ <Text
207
+ className="text-sm text-gray-600 dark:text-gray-300 mt-0.5"
208
+ numberOfLines={2}
209
+ >
210
+ {message}
211
+ </Text>
212
+ )}
213
+ </View>
214
+
215
+ {/* Action Button */}
216
+ {action && (
217
+ <Pressable
218
+ onPress={() => {
219
+ action.onPress();
220
+ handleDismiss();
221
+ }}
222
+ className="ml-2 px-3 py-1.5 bg-gray-100 dark:bg-gray-700 rounded-lg"
223
+ >
224
+ <Text className="text-sm font-medium text-primary-600 dark:text-primary-400">
225
+ {action.label}
226
+ </Text>
227
+ </Pressable>
228
+ )}
229
+
230
+ {/* Close button */}
231
+ <Pressable
232
+ onPress={handleDismiss}
233
+ className="ml-2 p-1"
234
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
235
+ accessibilityLabel="Dismiss notification"
236
+ accessibilityRole="button"
237
+ >
238
+ <Ionicons name="close" size={20} color="#9ca3af" />
239
+ </Pressable>
240
+ </Pressable>
241
+
242
+ {/* Progress bar */}
243
+ {duration > 0 && (
244
+ <View className="absolute bottom-0 left-4 right-4 h-1 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
245
+ <Animated.View
246
+ style={[
247
+ styles.progressBar,
248
+ progressStyle,
249
+ { backgroundColor: iconColor },
250
+ ]}
251
+ />
252
+ </View>
253
+ )}
254
+ </Animated.View>
255
+ );
256
+ }
257
+
258
+ // ============================================================================
259
+ // Toast Provider
260
+ // ============================================================================
261
+
262
+ interface ToastProviderProps {
263
+ children: ReactNode;
264
+ /** Maximum number of toasts to show at once */
265
+ maxToasts?: number;
266
+ }
267
+
268
+ /**
269
+ * Toast provider component. Wrap your app with this to enable toasts.
270
+ *
271
+ * @example
272
+ * ```tsx
273
+ * export default function App() {
274
+ * return (
275
+ * <ToastProvider maxToasts={3}>
276
+ * <NavigationContainer>
277
+ * <RootNavigator />
278
+ * </NavigationContainer>
279
+ * </ToastProvider>
280
+ * );
281
+ * }
282
+ * ```
283
+ */
284
+ export function ToastProvider({ children, maxToasts = 3 }: ToastProviderProps) {
285
+ const [toasts, setToasts] = useState<ToastConfig[]>([]);
286
+ const insets = useSafeAreaInsets();
287
+ const idCounter = useRef(0);
288
+
289
+ const dismiss = useCallback((id: string) => {
290
+ setToasts((prev) => prev.filter((t) => t.id !== id));
291
+ }, []);
292
+
293
+ const dismissAll = useCallback(() => {
294
+ setToasts([]);
295
+ }, []);
296
+
297
+ const show = useCallback(
298
+ (config: Omit<ToastConfig, "id">): string => {
299
+ const id = `toast-${++idCounter.current}`;
300
+ const newToast: ToastConfig = { ...config, id };
301
+
302
+ setToasts((prev) => {
303
+ const updated = [newToast, ...prev];
304
+ // Limit number of toasts
305
+ return updated.slice(0, maxToasts);
306
+ });
307
+
308
+ return id;
309
+ },
310
+ [maxToasts]
311
+ );
312
+
313
+ const success = useCallback(
314
+ (title: string, message?: string) =>
315
+ show({ type: "success", title, message }),
316
+ [show]
317
+ );
318
+
319
+ const error = useCallback(
320
+ (title: string, message?: string) =>
321
+ show({ type: "error", title, message }),
322
+ [show]
323
+ );
324
+
325
+ const warning = useCallback(
326
+ (title: string, message?: string) =>
327
+ show({ type: "warning", title, message }),
328
+ [show]
329
+ );
330
+
331
+ const info = useCallback(
332
+ (title: string, message?: string) => show({ type: "info", title, message }),
333
+ [show]
334
+ );
335
+
336
+ return (
337
+ <ToastContext.Provider
338
+ value={{ show, success, error, warning, info, dismiss, dismissAll }}
339
+ >
340
+ {children}
341
+ <View
342
+ style={[styles.container, { top: insets.top + 8 }]}
343
+ pointerEvents="box-none"
344
+ >
345
+ {toasts.map((toast) => (
346
+ <ToastItem key={toast.id} config={toast} onDismiss={dismiss} />
347
+ ))}
348
+ </View>
349
+ </ToastContext.Provider>
350
+ );
351
+ }
352
+
353
+ // ============================================================================
354
+ // Styles
355
+ // ============================================================================
356
+
357
+ const { width } = Dimensions.get("window");
358
+
359
+ const styles = StyleSheet.create({
360
+ container: {
361
+ position: "absolute",
362
+ left: 16,
363
+ right: 16,
364
+ zIndex: 9999,
365
+ alignItems: "center",
366
+ },
367
+ toastContainer: {
368
+ width: width - 32,
369
+ marginBottom: 8,
370
+ },
371
+ toastContent: {
372
+ flexDirection: "row",
373
+ alignItems: "center",
374
+ padding: 12,
375
+ paddingBottom: 16,
376
+ },
377
+ textContainer: {
378
+ flex: 1,
379
+ },
380
+ progressBar: {
381
+ height: "100%",
382
+ borderRadius: 2,
383
+ },
384
+ });
385
+
386
+ // ============================================================================
387
+ // Imperative API (for use outside React components)
388
+ // ============================================================================
389
+
390
+ let toastRef: ToastContextValue | null = null;
391
+
392
+ export function setToastRef(ref: ToastContextValue | null) {
393
+ toastRef = ref;
394
+ }
395
+
396
+ /**
397
+ * Imperative toast API for use outside React components.
398
+ * Must call setToastRef first from within ToastProvider.
399
+ *
400
+ * @example
401
+ * ```tsx
402
+ * // In your root component:
403
+ * function App() {
404
+ * const toastContext = useToast();
405
+ * useEffect(() => {
406
+ * setToastRef(toastContext);
407
+ * return () => setToastRef(null);
408
+ * }, [toastContext]);
409
+ * // ...
410
+ * }
411
+ *
412
+ * // Then anywhere:
413
+ * import { toastManager } from '@/components/ui/Toast';
414
+ * toastManager.success('Done!');
415
+ * ```
416
+ */
417
+ export const toastManager = {
418
+ show: (config: Omit<ToastConfig, "id">) => toastRef?.show(config),
419
+ success: (title: string, message?: string) =>
420
+ toastRef?.success(title, message),
421
+ error: (title: string, message?: string) => toastRef?.error(title, message),
422
+ warning: (title: string, message?: string) =>
423
+ toastRef?.warning(title, message),
424
+ info: (title: string, message?: string) => toastRef?.info(title, message),
425
+ dismiss: (id: string) => toastRef?.dismiss(id),
426
+ dismissAll: () => toastRef?.dismissAll(),
427
+ };