@croacroa/react-native-template 2.0.1 → 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.
- package/.env.example +5 -0
- package/.eslintrc.js +8 -0
- package/.github/workflows/ci.yml +187 -187
- package/.github/workflows/eas-build.yml +55 -55
- package/.github/workflows/eas-update.yml +50 -50
- package/.github/workflows/npm-publish.yml +57 -0
- package/CHANGELOG.md +195 -106
- package/CONTRIBUTING.md +377 -377
- package/LICENSE +21 -0
- package/README.md +446 -399
- package/__tests__/accessibility/components.test.tsx +285 -0
- package/__tests__/components/Button.test.tsx +2 -4
- package/__tests__/components/__snapshots__/snapshots.test.tsx.snap +512 -0
- package/__tests__/components/snapshots.test.tsx +131 -131
- package/__tests__/helpers/a11y.ts +54 -0
- package/__tests__/hooks/useAnalytics.test.ts +100 -0
- package/__tests__/hooks/useAnimations.test.ts +70 -0
- package/__tests__/hooks/useAuth.test.tsx +71 -28
- package/__tests__/hooks/useMedia.test.ts +318 -0
- package/__tests__/hooks/usePayments.test.tsx +307 -0
- package/__tests__/hooks/usePermission.test.ts +230 -0
- package/__tests__/hooks/useWebSocket.test.ts +329 -0
- package/__tests__/integration/auth-api.test.tsx +224 -227
- package/__tests__/performance/VirtualizedList.perf.test.tsx +385 -362
- package/__tests__/services/api.test.ts +24 -6
- package/app/(auth)/home.tsx +11 -9
- package/app/(auth)/profile.tsx +8 -6
- package/app/(auth)/settings.tsx +11 -9
- package/app/(public)/forgot-password.tsx +25 -15
- package/app/(public)/login.tsx +48 -12
- package/app/(public)/onboarding.tsx +5 -5
- package/app/(public)/register.tsx +24 -15
- package/app/_layout.tsx +6 -3
- package/app.config.ts +27 -2
- package/assets/images/.gitkeep +7 -7
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/components/ErrorBoundary.tsx +73 -28
- package/components/auth/SocialLoginButtons.tsx +168 -0
- package/components/forms/FormInput.tsx +5 -3
- package/components/onboarding/OnboardingScreen.tsx +370 -370
- package/components/onboarding/index.ts +2 -2
- package/components/providers/AnalyticsProvider.tsx +67 -0
- package/components/providers/SuspenseBoundary.tsx +359 -357
- package/components/providers/index.ts +24 -21
- package/components/ui/AnimatedButton.tsx +1 -9
- package/components/ui/AnimatedList.tsx +98 -0
- package/components/ui/AnimatedScreen.tsx +89 -0
- package/components/ui/Avatar.tsx +319 -316
- package/components/ui/Badge.tsx +416 -416
- package/components/ui/BottomSheet.tsx +307 -307
- package/components/ui/Button.tsx +11 -3
- package/components/ui/Checkbox.tsx +261 -261
- package/components/ui/FeatureGate.tsx +57 -0
- package/components/ui/ForceUpdateScreen.tsx +108 -0
- package/components/ui/ImagePickerButton.tsx +180 -0
- package/components/ui/Input.stories.tsx +2 -10
- package/components/ui/Input.tsx +2 -10
- package/components/ui/OptimizedImage.tsx +369 -369
- package/components/ui/Paywall.tsx +253 -0
- package/components/ui/PermissionGate.tsx +155 -0
- package/components/ui/PurchaseButton.tsx +84 -0
- package/components/ui/Select.tsx +240 -240
- package/components/ui/Skeleton.tsx +3 -1
- package/components/ui/Toast.tsx +427 -0
- package/components/ui/UploadProgress.tsx +189 -0
- package/components/ui/VirtualizedList.tsx +288 -285
- package/components/ui/index.ts +28 -23
- package/constants/config.ts +135 -97
- package/docs/adr/001-state-management.md +79 -79
- package/docs/adr/002-styling-approach.md +130 -130
- package/docs/adr/003-data-fetching.md +155 -155
- package/docs/adr/004-auth-adapter-pattern.md +144 -144
- package/docs/adr/README.md +78 -78
- package/docs/guides/analytics-posthog.md +121 -0
- package/docs/guides/auth-supabase.md +162 -0
- package/docs/guides/feature-flags-launchdarkly.md +150 -0
- package/docs/guides/payments-revenuecat.md +169 -0
- package/docs/plans/2026-02-22-phase6-implementation.md +3222 -0
- package/docs/plans/2026-02-22-phase6-template-completion-design.md +196 -0
- package/docs/plans/2026-02-23-npm-publish-design.md +31 -0
- package/docs/plans/2026-02-23-phase7-polish-documentation-design.md +79 -0
- package/docs/plans/2026-02-23-phase8-additional-features-design.md +136 -0
- package/eas.json +2 -1
- package/hooks/index.ts +70 -27
- package/hooks/useAnimatedEntry.ts +204 -0
- package/hooks/useApi.ts +64 -4
- package/hooks/useAuth.tsx +7 -3
- package/hooks/useBiometrics.ts +295 -295
- package/hooks/useChannel.ts +111 -0
- package/hooks/useDeepLinking.ts +256 -256
- package/hooks/useExperiment.ts +36 -0
- package/hooks/useFeatureFlag.ts +59 -0
- package/hooks/useForceUpdate.ts +91 -0
- package/hooks/useImagePicker.ts +281 -0
- package/hooks/useInAppReview.ts +64 -0
- package/hooks/useMFA.ts +509 -499
- package/hooks/useParallax.ts +142 -0
- package/hooks/usePerformance.ts +434 -434
- package/hooks/usePermission.ts +190 -0
- package/hooks/usePresence.ts +129 -0
- package/hooks/useProducts.ts +36 -0
- package/hooks/usePurchase.ts +103 -0
- package/hooks/useRateLimit.ts +70 -0
- package/hooks/useSubscription.ts +49 -0
- package/hooks/useTrackEvent.ts +52 -0
- package/hooks/useTrackScreen.ts +40 -0
- package/hooks/useUpdates.ts +358 -358
- package/hooks/useUpload.ts +165 -0
- package/hooks/useWebSocket.ts +111 -0
- package/i18n/index.ts +197 -194
- package/i18n/locales/ar.json +170 -101
- package/i18n/locales/de.json +170 -101
- package/i18n/locales/en.json +170 -101
- package/i18n/locales/es.json +170 -101
- package/i18n/locales/fr.json +170 -101
- package/jest.config.js +1 -1
- package/maestro/README.md +113 -113
- package/maestro/config.yaml +35 -35
- package/maestro/flows/login.yaml +62 -62
- package/maestro/flows/mfa-login.yaml +92 -92
- package/maestro/flows/mfa-setup.yaml +86 -86
- package/maestro/flows/navigation.yaml +68 -68
- package/maestro/flows/offline-conflict.yaml +101 -101
- package/maestro/flows/offline-sync.yaml +128 -128
- package/maestro/flows/offline.yaml +60 -60
- package/maestro/flows/register.yaml +94 -94
- package/package.json +188 -175
- package/scripts/generate-placeholders.js +38 -0
- package/services/analytics/adapters/console.ts +50 -0
- package/services/analytics/analytics-adapter.ts +94 -0
- package/services/analytics/types.ts +73 -0
- package/services/analytics.ts +428 -428
- package/services/api.ts +419 -340
- package/services/auth/social/apple.ts +110 -0
- package/services/auth/social/google.ts +159 -0
- package/services/auth/social/social-auth.ts +100 -0
- package/services/auth/social/types.ts +80 -0
- package/services/authAdapter.ts +333 -333
- package/services/backgroundSync.ts +652 -626
- package/services/feature-flags/adapters/mock.ts +108 -0
- package/services/feature-flags/feature-flag-adapter.ts +174 -0
- package/services/feature-flags/types.ts +79 -0
- package/services/force-update.ts +140 -0
- package/services/index.ts +116 -54
- package/services/media/compression.ts +91 -0
- package/services/media/media-picker.ts +151 -0
- package/services/media/media-upload.ts +160 -0
- package/services/payments/adapters/mock.ts +159 -0
- package/services/payments/payment-adapter.ts +118 -0
- package/services/payments/types.ts +131 -0
- package/services/permissions/permission-manager.ts +284 -0
- package/services/permissions/types.ts +104 -0
- package/services/realtime/types.ts +100 -0
- package/services/realtime/websocket-manager.ts +441 -0
- package/services/security.ts +289 -286
- package/services/sentry.ts +4 -4
- package/stores/appStore.ts +9 -0
- package/stores/notificationStore.ts +3 -1
- package/tailwind.config.js +47 -47
- package/tsconfig.json +37 -13
- package/types/user.ts +1 -1
- package/utils/accessibility.ts +446 -446
- package/utils/animations/presets.ts +182 -0
- package/utils/animations/transitions.ts +62 -0
- package/utils/index.ts +63 -52
- package/utils/toast.ts +9 -2
- package/utils/validation.ts +4 -1
- package/utils/withAccessibility.tsx +272 -272
|
@@ -0,0 +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
|
+
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
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Upload progress bar component
|
|
3
|
+
* Displays upload progress with percentage, cancel, retry, and error states.
|
|
4
|
+
* @module components/ui/UploadProgress
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { View, Text, Pressable } from "react-native";
|
|
8
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
9
|
+
import { useTranslation } from "react-i18next";
|
|
10
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
11
|
+
import type { UploadProgress as UploadProgressType } from "@/services/media/media-upload";
|
|
12
|
+
import { cn } from "@/utils/cn";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Props for the UploadProgress component
|
|
16
|
+
*/
|
|
17
|
+
interface UploadProgressProps {
|
|
18
|
+
/** Current upload progress */
|
|
19
|
+
progress: UploadProgressType | null;
|
|
20
|
+
/** Whether an upload is currently in progress */
|
|
21
|
+
isUploading: boolean;
|
|
22
|
+
/** Error message if the upload failed */
|
|
23
|
+
error?: string | null;
|
|
24
|
+
/** Callback to cancel the upload */
|
|
25
|
+
onCancel?: () => void;
|
|
26
|
+
/** Callback to retry a failed upload */
|
|
27
|
+
onRetry?: () => void;
|
|
28
|
+
/** Additional class name for the container */
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Upload progress indicator with error and cancel/retry support.
|
|
34
|
+
* Returns null when there is nothing to show (not uploading, no error).
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* function FileUploader() {
|
|
39
|
+
* const { upload, progress, isUploading, error, cancel, reset } = useUpload({
|
|
40
|
+
* url: 'https://api.example.com/upload',
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* return (
|
|
44
|
+
* <View>
|
|
45
|
+
* <Button onPress={() => upload(fileUri)}>Upload</Button>
|
|
46
|
+
* <UploadProgress
|
|
47
|
+
* progress={progress}
|
|
48
|
+
* isUploading={isUploading}
|
|
49
|
+
* error={error}
|
|
50
|
+
* onCancel={cancel}
|
|
51
|
+
* onRetry={() => upload(fileUri)}
|
|
52
|
+
* />
|
|
53
|
+
* </View>
|
|
54
|
+
* );
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function UploadProgress({
|
|
59
|
+
progress,
|
|
60
|
+
isUploading,
|
|
61
|
+
error,
|
|
62
|
+
onCancel,
|
|
63
|
+
onRetry,
|
|
64
|
+
className,
|
|
65
|
+
}: UploadProgressProps) {
|
|
66
|
+
const { isDark } = useTheme();
|
|
67
|
+
const { t } = useTranslation();
|
|
68
|
+
|
|
69
|
+
// Nothing to show
|
|
70
|
+
if (!isUploading && !error) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Error state
|
|
75
|
+
if (error) {
|
|
76
|
+
return (
|
|
77
|
+
<View
|
|
78
|
+
className={cn(
|
|
79
|
+
"flex-row items-center rounded-lg px-4 py-3",
|
|
80
|
+
isDark ? "bg-red-900/30" : "bg-red-50",
|
|
81
|
+
className
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<Ionicons
|
|
85
|
+
name="alert-circle"
|
|
86
|
+
size={20}
|
|
87
|
+
color={isDark ? "#fca5a5" : "#ef4444"}
|
|
88
|
+
/>
|
|
89
|
+
<Text
|
|
90
|
+
className={cn(
|
|
91
|
+
"ml-2 flex-1 text-sm font-medium",
|
|
92
|
+
isDark ? "text-red-300" : "text-red-600"
|
|
93
|
+
)}
|
|
94
|
+
numberOfLines={2}
|
|
95
|
+
>
|
|
96
|
+
{t("upload.failed")}
|
|
97
|
+
</Text>
|
|
98
|
+
{onRetry && (
|
|
99
|
+
<Pressable
|
|
100
|
+
onPress={onRetry}
|
|
101
|
+
className={cn(
|
|
102
|
+
"ml-2 rounded-md px-3 py-1.5",
|
|
103
|
+
isDark
|
|
104
|
+
? "bg-red-800/50 active:bg-red-800"
|
|
105
|
+
: "bg-red-100 active:bg-red-200"
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<Text
|
|
109
|
+
className={cn(
|
|
110
|
+
"text-sm font-semibold",
|
|
111
|
+
isDark ? "text-red-300" : "text-red-600"
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
{t("upload.retry")}
|
|
115
|
+
</Text>
|
|
116
|
+
</Pressable>
|
|
117
|
+
)}
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Uploading state
|
|
123
|
+
const percentage = progress?.percentage ?? 0;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<View
|
|
127
|
+
className={cn(
|
|
128
|
+
"rounded-lg px-4 py-3",
|
|
129
|
+
isDark ? "bg-gray-800" : "bg-gray-50",
|
|
130
|
+
className
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
{/* Header row */}
|
|
134
|
+
<View className="mb-2 flex-row items-center justify-between">
|
|
135
|
+
<View className="flex-row items-center">
|
|
136
|
+
<Text
|
|
137
|
+
className={cn(
|
|
138
|
+
"text-sm font-medium",
|
|
139
|
+
isDark ? "text-gray-200" : "text-gray-700"
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{t("upload.uploading")}
|
|
143
|
+
</Text>
|
|
144
|
+
<Text
|
|
145
|
+
className={cn(
|
|
146
|
+
"ml-2 text-sm",
|
|
147
|
+
isDark ? "text-gray-400" : "text-gray-500"
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{percentage}%
|
|
151
|
+
</Text>
|
|
152
|
+
</View>
|
|
153
|
+
{onCancel && (
|
|
154
|
+
<Pressable
|
|
155
|
+
onPress={onCancel}
|
|
156
|
+
className={cn(
|
|
157
|
+
"rounded-md px-3 py-1",
|
|
158
|
+
isDark
|
|
159
|
+
? "bg-gray-700 active:bg-gray-600"
|
|
160
|
+
: "bg-gray-200 active:bg-gray-300"
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
<Text
|
|
164
|
+
className={cn(
|
|
165
|
+
"text-xs font-semibold",
|
|
166
|
+
isDark ? "text-gray-300" : "text-gray-600"
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
Cancel
|
|
170
|
+
</Text>
|
|
171
|
+
</Pressable>
|
|
172
|
+
)}
|
|
173
|
+
</View>
|
|
174
|
+
|
|
175
|
+
{/* Progress bar */}
|
|
176
|
+
<View
|
|
177
|
+
className={cn(
|
|
178
|
+
"h-2 overflow-hidden rounded-full",
|
|
179
|
+
isDark ? "bg-gray-700" : "bg-gray-200"
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
<View
|
|
183
|
+
className="h-full rounded-full bg-primary-500"
|
|
184
|
+
style={{ width: `${percentage}%` }}
|
|
185
|
+
/>
|
|
186
|
+
</View>
|
|
187
|
+
</View>
|
|
188
|
+
);
|
|
189
|
+
}
|