@croacroa/react-native-template 2.0.1 → 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/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/package.json +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
|
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@croacroa/react-native-template",
|
|
3
|
-
"version": "2.0
|
|
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",
|