@croacroa/react-native-template 2.0.0 → 2.1.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/LICENSE +21 -0
- package/README.md +3 -0
- package/components/providers/SuspenseBoundary.tsx +6 -6
- package/components/providers/index.ts +9 -1
- package/components/ui/Toast.tsx +418 -0
- package/components/ui/index.ts +7 -0
- package/hooks/index.ts +13 -0
- package/hooks/useApi.ts +59 -0
- package/hooks/useImagePicker.ts +375 -0
- package/hooks/useOffline.ts +30 -6
- package/package.json +2 -1
- package/services/security.ts +58 -1
- package/utils/validation.ts +2 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Croacroa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@croacroa/react-native-template)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://www.typescriptlang.org/)
|
|
6
|
+
[](https://expo.dev/)
|
|
7
|
+
[](https://github.com/croacroa-dev-team/template-react-native/pulls)
|
|
5
8
|
|
|
6
9
|
A production-ready React Native template with Expo SDK 52, featuring authentication, i18n, biometrics, offline support, and more.
|
|
7
10
|
|
|
@@ -56,7 +56,7 @@ interface ErrorBoundaryProps {
|
|
|
56
56
|
* </ErrorBoundary>
|
|
57
57
|
* ```
|
|
58
58
|
*/
|
|
59
|
-
export class
|
|
59
|
+
export class LocalErrorBoundary extends Component<
|
|
60
60
|
ErrorBoundaryProps,
|
|
61
61
|
ErrorBoundaryState
|
|
62
62
|
> {
|
|
@@ -232,7 +232,7 @@ export function AsyncBoundary({
|
|
|
232
232
|
fallback: loadingFallback,
|
|
233
233
|
}: AsyncBoundaryProps) {
|
|
234
234
|
return (
|
|
235
|
-
<
|
|
235
|
+
<LocalErrorBoundary
|
|
236
236
|
key={resetKey}
|
|
237
237
|
fallback={errorFallback}
|
|
238
238
|
onError={onError}
|
|
@@ -246,7 +246,7 @@ export function AsyncBoundary({
|
|
|
246
246
|
>
|
|
247
247
|
{children}
|
|
248
248
|
</SuspenseBoundary>
|
|
249
|
-
</
|
|
249
|
+
</LocalErrorBoundary>
|
|
250
250
|
);
|
|
251
251
|
}
|
|
252
252
|
|
|
@@ -298,11 +298,11 @@ export function QueryBoundary({
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
return (
|
|
301
|
-
<
|
|
301
|
+
<LocalErrorBoundary fallback={errorFallback}>
|
|
302
302
|
<Suspense fallback={loadingFallback || <DefaultLoadingFallback />}>
|
|
303
303
|
{children}
|
|
304
304
|
</Suspense>
|
|
305
|
-
</
|
|
305
|
+
</LocalErrorBoundary>
|
|
306
306
|
);
|
|
307
307
|
}
|
|
308
308
|
|
|
@@ -351,7 +351,7 @@ export function BoundaryProvider({ children }: { children: ReactNode }) {
|
|
|
351
351
|
|
|
352
352
|
return (
|
|
353
353
|
<BoundaryContext.Provider value={{ resetAll, reportError }}>
|
|
354
|
-
<
|
|
354
|
+
<LocalErrorBoundary key={resetKey}>{children}</LocalErrorBoundary>
|
|
355
355
|
</BoundaryContext.Provider>
|
|
356
356
|
);
|
|
357
357
|
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Provider components for app-wide functionality
|
|
3
3
|
* @module components/providers
|
|
4
|
+
*
|
|
5
|
+
* Error Boundary Usage:
|
|
6
|
+
* - Use `ErrorBoundary` (from components/ErrorBoundary) for app root with Sentry integration
|
|
7
|
+
* - Use `LocalErrorBoundary` for local async patterns (lighter, no Sentry)
|
|
4
8
|
*/
|
|
5
9
|
|
|
10
|
+
// Main ErrorBoundary with Sentry integration - use at app root
|
|
11
|
+
export { ErrorBoundary, withErrorBoundary } from "../ErrorBoundary";
|
|
12
|
+
|
|
13
|
+
// Local boundaries for async patterns - lighter weight, no Sentry
|
|
6
14
|
export {
|
|
7
|
-
|
|
15
|
+
LocalErrorBoundary,
|
|
8
16
|
SuspenseBoundary,
|
|
9
17
|
AsyncBoundary,
|
|
10
18
|
QueryBoundary,
|
|
@@ -0,0 +1,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
|
+
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
|
+
};
|
package/components/ui/index.ts
CHANGED
package/hooks/index.ts
CHANGED
|
@@ -5,6 +5,8 @@ export {
|
|
|
5
5
|
useCurrentUser,
|
|
6
6
|
useUser,
|
|
7
7
|
useUpdateUser,
|
|
8
|
+
useSuspenseCurrentUser,
|
|
9
|
+
useSuspenseUser,
|
|
8
10
|
queryKeys,
|
|
9
11
|
createCrudHooks,
|
|
10
12
|
postsApi,
|
|
@@ -25,3 +27,14 @@ export {
|
|
|
25
27
|
} from "./usePerformance";
|
|
26
28
|
export { useMFA, generateTOTP } from "./useMFA";
|
|
27
29
|
export type { MFAMethod, MFASetupData } from "./useMFA";
|
|
30
|
+
export {
|
|
31
|
+
useImagePicker,
|
|
32
|
+
getFileExtension,
|
|
33
|
+
getMimeType,
|
|
34
|
+
prepareImageForUpload,
|
|
35
|
+
} from "./useImagePicker";
|
|
36
|
+
export type {
|
|
37
|
+
ImagePickerOptions,
|
|
38
|
+
SelectedImage,
|
|
39
|
+
UseImagePickerReturn,
|
|
40
|
+
} from "./useImagePicker";
|
package/hooks/useApi.ts
CHANGED
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
useQuery,
|
|
9
9
|
useMutation,
|
|
10
10
|
useQueryClient,
|
|
11
|
+
useSuspenseQuery,
|
|
11
12
|
UseQueryOptions,
|
|
12
13
|
UseMutationOptions,
|
|
14
|
+
UseSuspenseQueryOptions,
|
|
13
15
|
} from "@tanstack/react-query";
|
|
14
16
|
import { api } from "@/services/api";
|
|
15
17
|
import { toast, handleApiError } from "@/utils/toast";
|
|
@@ -120,6 +122,63 @@ export function useUser(
|
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
// ===========================================
|
|
126
|
+
// Suspense-Ready Hooks (React 19 compatible)
|
|
127
|
+
// ===========================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Suspense-ready version of useCurrentUser.
|
|
131
|
+
* Use inside a Suspense boundary - throws promise while loading.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* ```tsx
|
|
135
|
+
* function Profile() {
|
|
136
|
+
* const { data: user } = useSuspenseCurrentUser();
|
|
137
|
+
* // No loading check needed - Suspense handles it
|
|
138
|
+
* return <Text>Hello, {user.name}</Text>;
|
|
139
|
+
* }
|
|
140
|
+
*
|
|
141
|
+
* // Wrap with Suspense
|
|
142
|
+
* <Suspense fallback={<Skeleton />}>
|
|
143
|
+
* <Profile />
|
|
144
|
+
* </Suspense>
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function useSuspenseCurrentUser(
|
|
148
|
+
options?: Omit<UseSuspenseQueryOptions<User, Error>, "queryKey" | "queryFn">
|
|
149
|
+
) {
|
|
150
|
+
return useSuspenseQuery({
|
|
151
|
+
queryKey: queryKeys.users.me(),
|
|
152
|
+
queryFn: () => api.get<User>("/users/me"),
|
|
153
|
+
staleTime: 1000 * 60 * 5,
|
|
154
|
+
...options,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Suspense-ready version of useUser.
|
|
160
|
+
* Use inside a Suspense boundary - throws promise while loading.
|
|
161
|
+
*
|
|
162
|
+
* @param userId - The unique identifier of the user to fetch
|
|
163
|
+
* @example
|
|
164
|
+
* ```tsx
|
|
165
|
+
* function UserCard({ userId }: { userId: string }) {
|
|
166
|
+
* const { data: user } = useSuspenseUser(userId);
|
|
167
|
+
* return <Avatar name={user.name} />;
|
|
168
|
+
* }
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function useSuspenseUser(
|
|
172
|
+
userId: string,
|
|
173
|
+
options?: Omit<UseSuspenseQueryOptions<User, Error>, "queryKey" | "queryFn">
|
|
174
|
+
) {
|
|
175
|
+
return useSuspenseQuery({
|
|
176
|
+
queryKey: queryKeys.users.detail(userId),
|
|
177
|
+
queryFn: () => api.get<User>(`/users/${userId}`),
|
|
178
|
+
...options,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
123
182
|
/**
|
|
124
183
|
* Update the current user's profile.
|
|
125
184
|
* Automatically updates the cache and shows a success/error toast.
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Image picker hook with permissions handling
|
|
3
|
+
* Provides a simple interface for picking images from library or camera.
|
|
4
|
+
* @module hooks/useImagePicker
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback } from "react";
|
|
8
|
+
import * as ImagePicker from "expo-image-picker";
|
|
9
|
+
import { Alert, Platform } from "react-native";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Image picker options
|
|
13
|
+
*/
|
|
14
|
+
export interface ImagePickerOptions {
|
|
15
|
+
/** Allow editing/cropping the image */
|
|
16
|
+
allowsEditing?: boolean;
|
|
17
|
+
/** Aspect ratio for cropping [width, height] */
|
|
18
|
+
aspect?: [number, number];
|
|
19
|
+
/** Image quality (0-1) */
|
|
20
|
+
quality?: number;
|
|
21
|
+
/** Media types to allow */
|
|
22
|
+
mediaTypes?: ImagePicker.MediaTypeOptions;
|
|
23
|
+
/** Allow multiple selection (library only) */
|
|
24
|
+
allowsMultipleSelection?: boolean;
|
|
25
|
+
/** Maximum number of images to select */
|
|
26
|
+
selectionLimit?: number;
|
|
27
|
+
/** Base64 encode the image */
|
|
28
|
+
base64?: boolean;
|
|
29
|
+
/** Include EXIF data */
|
|
30
|
+
exif?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Selected image result
|
|
35
|
+
*/
|
|
36
|
+
export interface SelectedImage {
|
|
37
|
+
uri: string;
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
type?: string;
|
|
41
|
+
fileName?: string;
|
|
42
|
+
fileSize?: number;
|
|
43
|
+
base64?: string;
|
|
44
|
+
exif?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hook return type
|
|
49
|
+
*/
|
|
50
|
+
export interface UseImagePickerReturn {
|
|
51
|
+
/** Currently selected image(s) */
|
|
52
|
+
images: SelectedImage[];
|
|
53
|
+
/** Whether an operation is in progress */
|
|
54
|
+
isLoading: boolean;
|
|
55
|
+
/** Last error that occurred */
|
|
56
|
+
error: string | null;
|
|
57
|
+
/** Pick image from library */
|
|
58
|
+
pickFromLibrary: (options?: ImagePickerOptions) => Promise<SelectedImage[] | null>;
|
|
59
|
+
/** Take photo with camera */
|
|
60
|
+
takePhoto: (options?: ImagePickerOptions) => Promise<SelectedImage | null>;
|
|
61
|
+
/** Show action sheet to choose source */
|
|
62
|
+
pickImage: (options?: ImagePickerOptions) => Promise<SelectedImage[] | null>;
|
|
63
|
+
/** Clear selected images */
|
|
64
|
+
clear: () => void;
|
|
65
|
+
/** Remove specific image by index */
|
|
66
|
+
removeImage: (index: number) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_OPTIONS: ImagePickerOptions = {
|
|
70
|
+
allowsEditing: true,
|
|
71
|
+
aspect: [1, 1],
|
|
72
|
+
quality: 0.8,
|
|
73
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
74
|
+
allowsMultipleSelection: false,
|
|
75
|
+
base64: false,
|
|
76
|
+
exif: false,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Convert ImagePicker asset to SelectedImage
|
|
81
|
+
*/
|
|
82
|
+
function assetToSelectedImage(asset: ImagePicker.ImagePickerAsset): SelectedImage {
|
|
83
|
+
return {
|
|
84
|
+
uri: asset.uri,
|
|
85
|
+
width: asset.width,
|
|
86
|
+
height: asset.height,
|
|
87
|
+
type: asset.mimeType,
|
|
88
|
+
fileName: asset.fileName ?? undefined,
|
|
89
|
+
fileSize: asset.fileSize ?? undefined,
|
|
90
|
+
base64: asset.base64 ?? undefined,
|
|
91
|
+
exif: asset.exif ?? undefined,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Hook for picking images from library or camera.
|
|
97
|
+
* Handles permissions automatically and provides a clean API.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* function AvatarPicker() {
|
|
102
|
+
* const { images, pickImage, isLoading } = useImagePicker();
|
|
103
|
+
*
|
|
104
|
+
* return (
|
|
105
|
+
* <Pressable onPress={() => pickImage({ aspect: [1, 1] })}>
|
|
106
|
+
* {images[0] ? (
|
|
107
|
+
* <Image source={{ uri: images[0].uri }} style={styles.avatar} />
|
|
108
|
+
* ) : (
|
|
109
|
+
* <Text>Select Avatar</Text>
|
|
110
|
+
* )}
|
|
111
|
+
* </Pressable>
|
|
112
|
+
* );
|
|
113
|
+
* }
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```tsx
|
|
118
|
+
* // Multiple image selection
|
|
119
|
+
* function GalleryPicker() {
|
|
120
|
+
* const { images, pickFromLibrary, removeImage } = useImagePicker();
|
|
121
|
+
*
|
|
122
|
+
* const handlePick = () => {
|
|
123
|
+
* pickFromLibrary({
|
|
124
|
+
* allowsMultipleSelection: true,
|
|
125
|
+
* selectionLimit: 5,
|
|
126
|
+
* });
|
|
127
|
+
* };
|
|
128
|
+
*
|
|
129
|
+
* return (
|
|
130
|
+
* <View>
|
|
131
|
+
* <Button onPress={handlePick}>Add Photos</Button>
|
|
132
|
+
* {images.map((img, i) => (
|
|
133
|
+
* <ImageThumb key={i} uri={img.uri} onRemove={() => removeImage(i)} />
|
|
134
|
+
* ))}
|
|
135
|
+
* </View>
|
|
136
|
+
* );
|
|
137
|
+
* }
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function useImagePicker(): UseImagePickerReturn {
|
|
141
|
+
const [images, setImages] = useState<SelectedImage[]>([]);
|
|
142
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
143
|
+
const [error, setError] = useState<string | null>(null);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Request camera permissions
|
|
147
|
+
*/
|
|
148
|
+
const requestCameraPermission = useCallback(async (): Promise<boolean> => {
|
|
149
|
+
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
|
150
|
+
if (status !== "granted") {
|
|
151
|
+
Alert.alert(
|
|
152
|
+
"Camera Permission Required",
|
|
153
|
+
"Please allow camera access in your device settings to take photos.",
|
|
154
|
+
[{ text: "OK" }]
|
|
155
|
+
);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
return true;
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Request media library permissions
|
|
163
|
+
*/
|
|
164
|
+
const requestLibraryPermission = useCallback(async (): Promise<boolean> => {
|
|
165
|
+
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
166
|
+
if (status !== "granted") {
|
|
167
|
+
Alert.alert(
|
|
168
|
+
"Photo Library Permission Required",
|
|
169
|
+
"Please allow photo library access in your device settings to select images.",
|
|
170
|
+
[{ text: "OK" }]
|
|
171
|
+
);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pick image from library
|
|
179
|
+
*/
|
|
180
|
+
const pickFromLibrary = useCallback(
|
|
181
|
+
async (options: ImagePickerOptions = {}): Promise<SelectedImage[] | null> => {
|
|
182
|
+
setError(null);
|
|
183
|
+
setIsLoading(true);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const hasPermission = await requestLibraryPermission();
|
|
187
|
+
if (!hasPermission) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
192
|
+
|
|
193
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
194
|
+
mediaTypes: mergedOptions.mediaTypes,
|
|
195
|
+
allowsEditing: mergedOptions.allowsMultipleSelection
|
|
196
|
+
? false
|
|
197
|
+
: mergedOptions.allowsEditing,
|
|
198
|
+
aspect: mergedOptions.aspect,
|
|
199
|
+
quality: mergedOptions.quality,
|
|
200
|
+
allowsMultipleSelection: mergedOptions.allowsMultipleSelection,
|
|
201
|
+
selectionLimit: mergedOptions.selectionLimit,
|
|
202
|
+
base64: mergedOptions.base64,
|
|
203
|
+
exif: mergedOptions.exif,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (result.canceled) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const selectedImages = result.assets.map(assetToSelectedImage);
|
|
211
|
+
|
|
212
|
+
if (mergedOptions.allowsMultipleSelection) {
|
|
213
|
+
setImages((prev) => [...prev, ...selectedImages]);
|
|
214
|
+
} else {
|
|
215
|
+
setImages(selectedImages);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return selectedImages;
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const message = err instanceof Error ? err.message : "Failed to pick image";
|
|
221
|
+
setError(message);
|
|
222
|
+
console.error("[useImagePicker] Library error:", err);
|
|
223
|
+
return null;
|
|
224
|
+
} finally {
|
|
225
|
+
setIsLoading(false);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
[requestLibraryPermission]
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Take photo with camera
|
|
233
|
+
*/
|
|
234
|
+
const takePhoto = useCallback(
|
|
235
|
+
async (options: ImagePickerOptions = {}): Promise<SelectedImage | null> => {
|
|
236
|
+
setError(null);
|
|
237
|
+
setIsLoading(true);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const hasPermission = await requestCameraPermission();
|
|
241
|
+
if (!hasPermission) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
246
|
+
|
|
247
|
+
const result = await ImagePicker.launchCameraAsync({
|
|
248
|
+
mediaTypes: mergedOptions.mediaTypes,
|
|
249
|
+
allowsEditing: mergedOptions.allowsEditing,
|
|
250
|
+
aspect: mergedOptions.aspect,
|
|
251
|
+
quality: mergedOptions.quality,
|
|
252
|
+
base64: mergedOptions.base64,
|
|
253
|
+
exif: mergedOptions.exif,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (result.canceled) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const selectedImage = assetToSelectedImage(result.assets[0]);
|
|
261
|
+
setImages([selectedImage]);
|
|
262
|
+
|
|
263
|
+
return selectedImage;
|
|
264
|
+
} catch (err) {
|
|
265
|
+
const message = err instanceof Error ? err.message : "Failed to take photo";
|
|
266
|
+
setError(message);
|
|
267
|
+
console.error("[useImagePicker] Camera error:", err);
|
|
268
|
+
return null;
|
|
269
|
+
} finally {
|
|
270
|
+
setIsLoading(false);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[requestCameraPermission]
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Show action sheet to choose source (library or camera)
|
|
278
|
+
*/
|
|
279
|
+
const pickImage = useCallback(
|
|
280
|
+
async (options: ImagePickerOptions = {}): Promise<SelectedImage[] | null> => {
|
|
281
|
+
return new Promise((resolve) => {
|
|
282
|
+
Alert.alert("Select Image", "Choose image source", [
|
|
283
|
+
{
|
|
284
|
+
text: "Camera",
|
|
285
|
+
onPress: async () => {
|
|
286
|
+
const result = await takePhoto(options);
|
|
287
|
+
resolve(result ? [result] : null);
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
text: "Photo Library",
|
|
292
|
+
onPress: async () => {
|
|
293
|
+
const result = await pickFromLibrary(options);
|
|
294
|
+
resolve(result);
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
text: "Cancel",
|
|
299
|
+
style: "cancel",
|
|
300
|
+
onPress: () => resolve(null),
|
|
301
|
+
},
|
|
302
|
+
]);
|
|
303
|
+
});
|
|
304
|
+
},
|
|
305
|
+
[takePhoto, pickFromLibrary]
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Clear all selected images
|
|
310
|
+
*/
|
|
311
|
+
const clear = useCallback(() => {
|
|
312
|
+
setImages([]);
|
|
313
|
+
setError(null);
|
|
314
|
+
}, []);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Remove image by index
|
|
318
|
+
*/
|
|
319
|
+
const removeImage = useCallback((index: number) => {
|
|
320
|
+
setImages((prev) => prev.filter((_, i) => i !== index));
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
images,
|
|
325
|
+
isLoading,
|
|
326
|
+
error,
|
|
327
|
+
pickFromLibrary,
|
|
328
|
+
takePhoto,
|
|
329
|
+
pickImage,
|
|
330
|
+
clear,
|
|
331
|
+
removeImage,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Utility to get file extension from URI
|
|
337
|
+
*/
|
|
338
|
+
export function getFileExtension(uri: string): string {
|
|
339
|
+
const match = uri.match(/\.(\w+)$/);
|
|
340
|
+
return match ? match[1].toLowerCase() : "jpg";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Utility to get MIME type from extension
|
|
345
|
+
*/
|
|
346
|
+
export function getMimeType(extension: string): string {
|
|
347
|
+
const mimeTypes: Record<string, string> = {
|
|
348
|
+
jpg: "image/jpeg",
|
|
349
|
+
jpeg: "image/jpeg",
|
|
350
|
+
png: "image/png",
|
|
351
|
+
gif: "image/gif",
|
|
352
|
+
webp: "image/webp",
|
|
353
|
+
heic: "image/heic",
|
|
354
|
+
heif: "image/heif",
|
|
355
|
+
};
|
|
356
|
+
return mimeTypes[extension.toLowerCase()] || "image/jpeg";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Prepare image for FormData upload
|
|
361
|
+
*/
|
|
362
|
+
export function prepareImageForUpload(
|
|
363
|
+
image: SelectedImage,
|
|
364
|
+
fieldName = "image"
|
|
365
|
+
): { uri: string; type: string; name: string } {
|
|
366
|
+
const extension = getFileExtension(image.uri);
|
|
367
|
+
const type = image.type || getMimeType(extension);
|
|
368
|
+
const name = image.fileName || `${fieldName}.${extension}`;
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
uri: Platform.OS === "ios" ? image.uri.replace("file://", "") : image.uri,
|
|
372
|
+
type,
|
|
373
|
+
name,
|
|
374
|
+
};
|
|
375
|
+
}
|
package/hooks/useOffline.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* @module hooks/useOffline
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { useEffect, useState } from "react";
|
|
7
|
+
import { useEffect, useState, useCallback } from "react";
|
|
8
8
|
import NetInfo, { NetInfoState } from "@react-native-community/netinfo";
|
|
9
9
|
import { onlineManager } from "@tanstack/react-query";
|
|
10
10
|
import { toast } from "@/utils/toast";
|
|
@@ -68,9 +68,10 @@ export function useOffline(options: UseOfflineOptions = {}) {
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Hook for tracking pending mutations count.
|
|
71
|
-
*
|
|
71
|
+
* Integrates with the backgroundSync mutation queue.
|
|
72
72
|
*
|
|
73
|
-
* @
|
|
73
|
+
* @param pollingInterval - How often to check the queue (default: 5000ms)
|
|
74
|
+
* @returns Object with pendingCount, hasPending flag, and refresh function
|
|
74
75
|
*
|
|
75
76
|
* @example
|
|
76
77
|
* ```tsx
|
|
@@ -87,13 +88,36 @@ export function useOffline(options: UseOfflineOptions = {}) {
|
|
|
87
88
|
* }
|
|
88
89
|
* ```
|
|
89
90
|
*/
|
|
90
|
-
export function usePendingMutations() {
|
|
91
|
+
export function usePendingMutations(pollingInterval = 5000) {
|
|
91
92
|
const [pendingCount, setPendingCount] = useState(0);
|
|
93
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
94
|
+
|
|
95
|
+
const refresh = useCallback(async () => {
|
|
96
|
+
try {
|
|
97
|
+
const { getMutationQueue } = await import("@/services/backgroundSync");
|
|
98
|
+
const queue = await getMutationQueue();
|
|
99
|
+
setPendingCount(queue.length);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error("[usePendingMutations] Failed to get queue:", error);
|
|
102
|
+
} finally {
|
|
103
|
+
setIsLoading(false);
|
|
104
|
+
}
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
// Initial fetch
|
|
109
|
+
refresh();
|
|
110
|
+
|
|
111
|
+
// Poll for updates
|
|
112
|
+
const interval = setInterval(refresh, pollingInterval);
|
|
113
|
+
|
|
114
|
+
return () => clearInterval(interval);
|
|
115
|
+
}, [refresh, pollingInterval]);
|
|
92
116
|
|
|
93
|
-
// This would need to be integrated with mutation cache
|
|
94
|
-
// For now, return 0 as a placeholder
|
|
95
117
|
return {
|
|
96
118
|
pendingCount,
|
|
97
119
|
hasPending: pendingCount > 0,
|
|
120
|
+
isLoading,
|
|
121
|
+
refresh,
|
|
98
122
|
};
|
|
99
123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@croacroa/react-native-template",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Production-ready React Native template with Expo, authentication, i18n, offline support, and more",
|
|
5
5
|
"main": "expo-router/entry",
|
|
6
6
|
"author": "Croacroa <contact@croacroa.dev>",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"expo-device": "~7.0.1",
|
|
76
76
|
"expo-font": "~13.0.2",
|
|
77
77
|
"expo-image": "~2.0.4",
|
|
78
|
+
"expo-image-picker": "^17.0.10",
|
|
78
79
|
"expo-linking": "~7.0.4",
|
|
79
80
|
"expo-local-authentication": "~15.0.1",
|
|
80
81
|
"expo-localization": "~16.0.0",
|
package/services/security.ts
CHANGED
|
@@ -199,6 +199,9 @@ export const SSL_PINNING_CONFIG = {
|
|
|
199
199
|
|
|
200
200
|
/**
|
|
201
201
|
* Generate Android network_security_config.xml content
|
|
202
|
+
* Save this to android/app/src/main/res/xml/network_security_config.xml
|
|
203
|
+
* and reference it in AndroidManifest.xml:
|
|
204
|
+
* <application android:networkSecurityConfig="@xml/network_security_config">
|
|
202
205
|
*/
|
|
203
206
|
getAndroidConfig(): string {
|
|
204
207
|
const pinEntries = Object.entries(SECURITY.SSL_PINS)
|
|
@@ -209,7 +212,7 @@ export const SSL_PINNING_CONFIG = {
|
|
|
209
212
|
|
|
210
213
|
return ` <domain-config cleartextTrafficPermitted="false">
|
|
211
214
|
<domain includeSubdomains="true">${domain}</domain>
|
|
212
|
-
<pin-set expiration="
|
|
215
|
+
<pin-set expiration="2026-12-31">
|
|
213
216
|
${pinElements}
|
|
214
217
|
</pin-set>
|
|
215
218
|
</domain-config>`;
|
|
@@ -226,4 +229,58 @@ ${pinElements}
|
|
|
226
229
|
${pinEntries}
|
|
227
230
|
</network-security-config>`;
|
|
228
231
|
},
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Generate iOS TrustKit configuration dictionary
|
|
235
|
+
* Add this to your AppDelegate.mm or use expo-build-properties plugin
|
|
236
|
+
*
|
|
237
|
+
* For Expo managed workflow, add to app.config.ts:
|
|
238
|
+
* ```
|
|
239
|
+
* plugins: [
|
|
240
|
+
* ["expo-build-properties", {
|
|
241
|
+
* ios: {
|
|
242
|
+
* infoPlist: SSL_PINNING_CONFIG.getIOSInfoPlist()
|
|
243
|
+
* }
|
|
244
|
+
* }]
|
|
245
|
+
* ]
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
getIOSConfig(): Record<string, unknown> {
|
|
249
|
+
const pinnedDomains: Record<string, unknown> = {};
|
|
250
|
+
|
|
251
|
+
Object.entries(SECURITY.SSL_PINS).forEach(([domain, pins]) => {
|
|
252
|
+
pinnedDomains[domain] = {
|
|
253
|
+
TSKIncludeSubdomains: true,
|
|
254
|
+
TSKEnforcePinning: true,
|
|
255
|
+
TSKPublicKeyHashes: pins.map((pin) => pin.replace("sha256/", "")),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
TSKSwizzleNetworkDelegates: true,
|
|
261
|
+
TSKPinnedDomains: pinnedDomains,
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Generate iOS Info.plist entries for SSL pinning
|
|
267
|
+
*/
|
|
268
|
+
getIOSInfoPlist(): Record<string, unknown> {
|
|
269
|
+
return {
|
|
270
|
+
NSAppTransportSecurity: {
|
|
271
|
+
NSAllowsArbitraryLoads: false,
|
|
272
|
+
NSPinnedDomains: Object.fromEntries(
|
|
273
|
+
Object.entries(SECURITY.SSL_PINS).map(([domain, pins]) => [
|
|
274
|
+
domain,
|
|
275
|
+
{
|
|
276
|
+
NSIncludesSubdomains: true,
|
|
277
|
+
NSPinnedLeafIdentities: pins.map((pin) => ({
|
|
278
|
+
"SPKI-SHA256-BASE64": pin.replace("sha256/", ""),
|
|
279
|
+
})),
|
|
280
|
+
},
|
|
281
|
+
])
|
|
282
|
+
),
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
},
|
|
229
286
|
};
|
package/utils/validation.ts
CHANGED
|
@@ -12,7 +12,8 @@ export const passwordSchema = z
|
|
|
12
12
|
.min(8, "Password must be at least 8 characters")
|
|
13
13
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
14
14
|
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
15
|
-
.regex(/[0-9]/, "Password must contain at least one number")
|
|
15
|
+
.regex(/[0-9]/, "Password must contain at least one number")
|
|
16
|
+
.regex(/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/, "Password must contain at least one special character");
|
|
16
17
|
|
|
17
18
|
export const nameSchema = z
|
|
18
19
|
.string()
|