@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 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
  [![npm version](https://img.shields.io/npm/v/@croacroa/react-native-template.svg)](https://www.npmjs.com/package/@croacroa/react-native-template)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-blue.svg)](https://www.typescriptlang.org/)
6
+ [![Expo SDK](https://img.shields.io/badge/Expo-SDK%2052-000020.svg)](https://expo.dev/)
7
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](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
+ };
@@ -21,3 +21,10 @@ export {
21
21
  VirtualizedList,
22
22
  HorizontalVirtualizedList,
23
23
  } from "./VirtualizedList";
24
+ export {
25
+ ToastProvider,
26
+ useToast,
27
+ toastManager,
28
+ setToastRef,
29
+ } from "./Toast";
30
+ export type { ToastConfig, ToastType } from "./Toast";
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.1",
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",