@croacroa/react-native-template 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/.eslintrc.js +55 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/eas-build.yml +55 -0
- package/.github/workflows/eas-update.yml +50 -0
- package/.gitignore +62 -0
- package/.prettierrc +11 -0
- package/.storybook/main.ts +28 -0
- package/.storybook/preview.tsx +30 -0
- package/CHANGELOG.md +106 -0
- package/CONTRIBUTING.md +377 -0
- package/README.md +399 -0
- package/__tests__/components/Button.test.tsx +74 -0
- package/__tests__/hooks/useAuth.test.tsx +499 -0
- package/__tests__/services/api.test.ts +535 -0
- package/__tests__/utils/cn.test.ts +39 -0
- package/app/(auth)/_layout.tsx +36 -0
- package/app/(auth)/home.tsx +117 -0
- package/app/(auth)/profile.tsx +152 -0
- package/app/(auth)/settings.tsx +147 -0
- package/app/(public)/_layout.tsx +21 -0
- package/app/(public)/forgot-password.tsx +127 -0
- package/app/(public)/login.tsx +120 -0
- package/app/(public)/onboarding.tsx +5 -0
- package/app/(public)/register.tsx +139 -0
- package/app/_layout.tsx +97 -0
- package/app/index.tsx +21 -0
- package/app.config.ts +72 -0
- package/assets/images/.gitkeep +7 -0
- package/assets/images/adaptive-icon.png +0 -0
- package/assets/images/favicon.png +0 -0
- package/assets/images/icon.png +0 -0
- package/assets/images/notification-icon.png +0 -0
- package/assets/images/splash.png +0 -0
- package/babel.config.js +10 -0
- package/components/ErrorBoundary.tsx +169 -0
- package/components/forms/FormInput.tsx +78 -0
- package/components/forms/index.ts +1 -0
- package/components/onboarding/OnboardingScreen.tsx +370 -0
- package/components/onboarding/index.ts +2 -0
- package/components/ui/AnimatedButton.tsx +156 -0
- package/components/ui/AnimatedCard.tsx +108 -0
- package/components/ui/Avatar.tsx +316 -0
- package/components/ui/Badge.tsx +416 -0
- package/components/ui/BottomSheet.tsx +307 -0
- package/components/ui/Button.stories.tsx +115 -0
- package/components/ui/Button.tsx +104 -0
- package/components/ui/Card.stories.tsx +84 -0
- package/components/ui/Card.tsx +32 -0
- package/components/ui/Checkbox.tsx +261 -0
- package/components/ui/Input.stories.tsx +106 -0
- package/components/ui/Input.tsx +117 -0
- package/components/ui/Modal.tsx +98 -0
- package/components/ui/OptimizedImage.tsx +369 -0
- package/components/ui/Select.tsx +240 -0
- package/components/ui/Skeleton.tsx +180 -0
- package/components/ui/index.ts +18 -0
- package/constants/config.ts +54 -0
- package/docs/adr/001-state-management.md +79 -0
- package/docs/adr/002-styling-approach.md +130 -0
- package/docs/adr/003-data-fetching.md +155 -0
- package/docs/adr/004-auth-adapter-pattern.md +144 -0
- package/docs/adr/README.md +78 -0
- package/eas.json +47 -0
- package/global.css +10 -0
- package/hooks/index.ts +25 -0
- package/hooks/useApi.ts +236 -0
- package/hooks/useAuth.tsx +290 -0
- package/hooks/useBiometrics.ts +295 -0
- package/hooks/useDeepLinking.ts +256 -0
- package/hooks/useNotifications.ts +138 -0
- package/hooks/useOffline.ts +69 -0
- package/hooks/usePerformance.ts +434 -0
- package/hooks/useTheme.tsx +85 -0
- package/hooks/useUpdates.ts +358 -0
- package/i18n/index.ts +77 -0
- package/i18n/locales/en.json +101 -0
- package/i18n/locales/fr.json +101 -0
- package/jest.config.js +32 -0
- package/maestro/README.md +113 -0
- package/maestro/config.yaml +35 -0
- package/maestro/flows/login.yaml +62 -0
- package/maestro/flows/navigation.yaml +68 -0
- package/maestro/flows/offline.yaml +60 -0
- package/maestro/flows/register.yaml +94 -0
- package/metro.config.js +6 -0
- package/nativewind-env.d.ts +1 -0
- package/package.json +170 -0
- package/scripts/init.ps1 +162 -0
- package/scripts/init.sh +174 -0
- package/services/analytics.ts +428 -0
- package/services/api.ts +340 -0
- package/services/authAdapter.ts +333 -0
- package/services/index.ts +22 -0
- package/services/queryClient.ts +97 -0
- package/services/sentry.ts +131 -0
- package/services/storage.ts +82 -0
- package/stores/appStore.ts +54 -0
- package/stores/index.ts +2 -0
- package/stores/notificationStore.ts +40 -0
- package/tailwind.config.js +47 -0
- package/tsconfig.json +26 -0
- package/types/index.ts +42 -0
- package/types/user.ts +63 -0
- package/utils/accessibility.ts +446 -0
- package/utils/cn.ts +14 -0
- package/utils/index.ts +43 -0
- package/utils/toast.ts +113 -0
- package/utils/validation.ts +67 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { Image, ImageProps, ImageContentFit } from "expo-image";
|
|
4
|
+
import Animated, {
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withTiming,
|
|
7
|
+
interpolate,
|
|
8
|
+
useSharedValue,
|
|
9
|
+
} from "react-native-reanimated";
|
|
10
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
11
|
+
import { cn } from "@/utils/cn";
|
|
12
|
+
|
|
13
|
+
const AnimatedImage = Animated.createAnimatedComponent(Image);
|
|
14
|
+
|
|
15
|
+
type ImagePriority = "low" | "normal" | "high";
|
|
16
|
+
|
|
17
|
+
interface OptimizedImageProps {
|
|
18
|
+
/**
|
|
19
|
+
* Image source URL
|
|
20
|
+
*/
|
|
21
|
+
source: string | number;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Alt text for accessibility
|
|
25
|
+
*/
|
|
26
|
+
alt?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Image width
|
|
30
|
+
*/
|
|
31
|
+
width?: number | string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Image height
|
|
35
|
+
*/
|
|
36
|
+
height?: number | string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Aspect ratio (e.g., 16/9, 1, 4/3)
|
|
40
|
+
*/
|
|
41
|
+
aspectRatio?: number;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How to fit the image in the container
|
|
45
|
+
*/
|
|
46
|
+
contentFit?: ImageContentFit;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Blur hash or thumbhash for placeholder
|
|
50
|
+
*/
|
|
51
|
+
placeholder?: string | number;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loading priority
|
|
55
|
+
*/
|
|
56
|
+
priority?: ImagePriority;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether to enable caching
|
|
60
|
+
*/
|
|
61
|
+
cachePolicy?: "none" | "disk" | "memory" | "memory-disk";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Transition duration in ms
|
|
65
|
+
*/
|
|
66
|
+
transitionDuration?: number;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Additional class name for container
|
|
70
|
+
*/
|
|
71
|
+
className?: string;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether to show loading skeleton
|
|
75
|
+
*/
|
|
76
|
+
showSkeleton?: boolean;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Border radius
|
|
80
|
+
*/
|
|
81
|
+
borderRadius?: number;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Called when image loads successfully
|
|
85
|
+
*/
|
|
86
|
+
onLoad?: () => void;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Called when image fails to load
|
|
90
|
+
*/
|
|
91
|
+
onError?: (error: Error) => void;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Style override
|
|
95
|
+
*/
|
|
96
|
+
style?: ImageProps["style"];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function OptimizedImage({
|
|
100
|
+
source,
|
|
101
|
+
alt,
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
aspectRatio,
|
|
105
|
+
contentFit = "cover",
|
|
106
|
+
placeholder,
|
|
107
|
+
priority = "normal",
|
|
108
|
+
cachePolicy = "memory-disk",
|
|
109
|
+
transitionDuration = 300,
|
|
110
|
+
className,
|
|
111
|
+
showSkeleton = true,
|
|
112
|
+
borderRadius = 0,
|
|
113
|
+
onLoad,
|
|
114
|
+
onError,
|
|
115
|
+
style,
|
|
116
|
+
}: OptimizedImageProps) {
|
|
117
|
+
const { isDark } = useTheme();
|
|
118
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
119
|
+
const [hasError, setHasError] = useState(false);
|
|
120
|
+
const opacity = useSharedValue(0);
|
|
121
|
+
|
|
122
|
+
const handleLoad = useCallback(() => {
|
|
123
|
+
setIsLoading(false);
|
|
124
|
+
opacity.value = withTiming(1, { duration: transitionDuration });
|
|
125
|
+
onLoad?.();
|
|
126
|
+
}, [opacity, transitionDuration, onLoad]);
|
|
127
|
+
|
|
128
|
+
const handleError = useCallback(
|
|
129
|
+
(error: { error: string }) => {
|
|
130
|
+
setIsLoading(false);
|
|
131
|
+
setHasError(true);
|
|
132
|
+
onError?.(new Error(error.error));
|
|
133
|
+
},
|
|
134
|
+
[onError]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
138
|
+
return {
|
|
139
|
+
opacity: interpolate(opacity.value, [0, 1], [0, 1]),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Determine the source
|
|
144
|
+
const imageSource = typeof source === "string" ? { uri: source } : source;
|
|
145
|
+
|
|
146
|
+
// Map priority to expo-image priority
|
|
147
|
+
const imagePriority =
|
|
148
|
+
priority === "high" ? "high" : priority === "low" ? "low" : "normal";
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<View
|
|
152
|
+
className={cn("overflow-hidden", className)}
|
|
153
|
+
style={[
|
|
154
|
+
{
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
aspectRatio,
|
|
158
|
+
borderRadius,
|
|
159
|
+
backgroundColor: isDark ? "#1e293b" : "#f1f5f9",
|
|
160
|
+
},
|
|
161
|
+
]}
|
|
162
|
+
>
|
|
163
|
+
{/* Skeleton loader */}
|
|
164
|
+
{showSkeleton && isLoading && !hasError && (
|
|
165
|
+
<SkeletonLoader borderRadius={borderRadius} />
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Error state */}
|
|
169
|
+
{hasError && (
|
|
170
|
+
<View
|
|
171
|
+
style={[styles.errorContainer, { borderRadius }]}
|
|
172
|
+
className={isDark ? "bg-gray-800" : "bg-gray-100"}
|
|
173
|
+
>
|
|
174
|
+
<View className="items-center justify-center">
|
|
175
|
+
<View
|
|
176
|
+
className={cn(
|
|
177
|
+
"w-12 h-12 rounded-full items-center justify-center mb-2",
|
|
178
|
+
isDark ? "bg-gray-700" : "bg-gray-200"
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
181
|
+
<ErrorIcon isDark={isDark} />
|
|
182
|
+
</View>
|
|
183
|
+
</View>
|
|
184
|
+
</View>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Actual image */}
|
|
188
|
+
{!hasError && (
|
|
189
|
+
<AnimatedImage
|
|
190
|
+
source={imageSource}
|
|
191
|
+
contentFit={contentFit}
|
|
192
|
+
placeholder={placeholder}
|
|
193
|
+
placeholderContentFit="cover"
|
|
194
|
+
transition={transitionDuration}
|
|
195
|
+
priority={imagePriority}
|
|
196
|
+
cachePolicy={cachePolicy}
|
|
197
|
+
onLoad={handleLoad}
|
|
198
|
+
onError={handleError}
|
|
199
|
+
accessibilityLabel={alt}
|
|
200
|
+
style={[styles.image, { borderRadius }, animatedStyle, style]}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</View>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Skeleton loader with shimmer effect
|
|
209
|
+
*/
|
|
210
|
+
function SkeletonLoader({ borderRadius }: { borderRadius: number }) {
|
|
211
|
+
const { isDark } = useTheme();
|
|
212
|
+
const shimmer = useSharedValue(0);
|
|
213
|
+
|
|
214
|
+
// Start shimmer animation
|
|
215
|
+
useState(() => {
|
|
216
|
+
shimmer.value = withTiming(1, { duration: 1500 }, () => {
|
|
217
|
+
shimmer.value = 0;
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const shimmerStyle = useAnimatedStyle(() => {
|
|
222
|
+
return {
|
|
223
|
+
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Animated.View
|
|
229
|
+
style={[
|
|
230
|
+
styles.skeleton,
|
|
231
|
+
{ borderRadius },
|
|
232
|
+
shimmerStyle,
|
|
233
|
+
{ backgroundColor: isDark ? "#334155" : "#e2e8f0" },
|
|
234
|
+
]}
|
|
235
|
+
/>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Error icon component
|
|
241
|
+
*/
|
|
242
|
+
function ErrorIcon({ isDark }: { isDark: boolean }) {
|
|
243
|
+
return (
|
|
244
|
+
<View
|
|
245
|
+
style={{
|
|
246
|
+
width: 24,
|
|
247
|
+
height: 24,
|
|
248
|
+
borderRadius: 12,
|
|
249
|
+
backgroundColor: isDark ? "#475569" : "#cbd5e1",
|
|
250
|
+
alignItems: "center",
|
|
251
|
+
justifyContent: "center",
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<View
|
|
255
|
+
style={{
|
|
256
|
+
width: 12,
|
|
257
|
+
height: 2,
|
|
258
|
+
backgroundColor: isDark ? "#94a3b8" : "#64748b",
|
|
259
|
+
transform: [{ rotate: "45deg" }],
|
|
260
|
+
}}
|
|
261
|
+
/>
|
|
262
|
+
</View>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const styles = StyleSheet.create({
|
|
267
|
+
image: {
|
|
268
|
+
width: "100%",
|
|
269
|
+
height: "100%",
|
|
270
|
+
position: "absolute",
|
|
271
|
+
top: 0,
|
|
272
|
+
left: 0,
|
|
273
|
+
},
|
|
274
|
+
skeleton: {
|
|
275
|
+
...StyleSheet.absoluteFillObject,
|
|
276
|
+
},
|
|
277
|
+
errorContainer: {
|
|
278
|
+
...StyleSheet.absoluteFillObject,
|
|
279
|
+
alignItems: "center",
|
|
280
|
+
justifyContent: "center",
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Background Image component
|
|
286
|
+
*/
|
|
287
|
+
interface BackgroundImageProps extends OptimizedImageProps {
|
|
288
|
+
children?: React.ReactNode;
|
|
289
|
+
overlayColor?: string;
|
|
290
|
+
overlayOpacity?: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function BackgroundImage({
|
|
294
|
+
children,
|
|
295
|
+
overlayColor = "#000000",
|
|
296
|
+
overlayOpacity = 0.4,
|
|
297
|
+
...props
|
|
298
|
+
}: BackgroundImageProps) {
|
|
299
|
+
return (
|
|
300
|
+
<View style={styles.backgroundContainer}>
|
|
301
|
+
<OptimizedImage {...props} style={StyleSheet.absoluteFill} />
|
|
302
|
+
{overlayOpacity > 0 && (
|
|
303
|
+
<View
|
|
304
|
+
style={[
|
|
305
|
+
StyleSheet.absoluteFill,
|
|
306
|
+
{
|
|
307
|
+
backgroundColor: overlayColor,
|
|
308
|
+
opacity: overlayOpacity,
|
|
309
|
+
},
|
|
310
|
+
]}
|
|
311
|
+
/>
|
|
312
|
+
)}
|
|
313
|
+
<View style={styles.backgroundContent}>{children}</View>
|
|
314
|
+
</View>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Image with progressive loading (blur to sharp)
|
|
320
|
+
*/
|
|
321
|
+
interface ProgressiveImageProps extends OptimizedImageProps {
|
|
322
|
+
/**
|
|
323
|
+
* Low-quality placeholder image
|
|
324
|
+
*/
|
|
325
|
+
thumbnail?: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function ProgressiveImage({
|
|
329
|
+
thumbnail,
|
|
330
|
+
source,
|
|
331
|
+
...props
|
|
332
|
+
}: ProgressiveImageProps) {
|
|
333
|
+
const [isFullLoaded, setIsFullLoaded] = useState(false);
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<View style={{ position: "relative", overflow: "hidden" }}>
|
|
337
|
+
{/* Thumbnail (blurred) */}
|
|
338
|
+
{thumbnail && !isFullLoaded && (
|
|
339
|
+
<Image
|
|
340
|
+
source={{ uri: thumbnail }}
|
|
341
|
+
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
|
|
342
|
+
contentFit="cover"
|
|
343
|
+
blurRadius={10}
|
|
344
|
+
/>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{/* Full resolution image */}
|
|
348
|
+
<OptimizedImage
|
|
349
|
+
{...props}
|
|
350
|
+
source={source}
|
|
351
|
+
onLoad={() => {
|
|
352
|
+
setIsFullLoaded(true);
|
|
353
|
+
props.onLoad?.();
|
|
354
|
+
}}
|
|
355
|
+
showSkeleton={!thumbnail}
|
|
356
|
+
/>
|
|
357
|
+
</View>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
Object.assign(styles, {
|
|
362
|
+
backgroundContainer: {
|
|
363
|
+
flex: 1,
|
|
364
|
+
},
|
|
365
|
+
backgroundContent: {
|
|
366
|
+
flex: 1,
|
|
367
|
+
zIndex: 1,
|
|
368
|
+
},
|
|
369
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TouchableOpacity,
|
|
6
|
+
Modal,
|
|
7
|
+
FlatList,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
11
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
12
|
+
import { cn } from "@/utils/cn";
|
|
13
|
+
|
|
14
|
+
export interface SelectOption<T = string> {
|
|
15
|
+
label: string;
|
|
16
|
+
value: T;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
icon?: keyof typeof Ionicons.glyphMap;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SelectProps<T = string> {
|
|
22
|
+
/**
|
|
23
|
+
* Available options
|
|
24
|
+
*/
|
|
25
|
+
options: SelectOption<T>[];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Currently selected value
|
|
29
|
+
*/
|
|
30
|
+
value?: T;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Callback when value changes
|
|
34
|
+
*/
|
|
35
|
+
onChange?: (value: T) => void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Placeholder text when no value selected
|
|
39
|
+
*/
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Label displayed above the select
|
|
44
|
+
*/
|
|
45
|
+
label?: string;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Error message
|
|
49
|
+
*/
|
|
50
|
+
error?: string;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether the select is disabled
|
|
54
|
+
*/
|
|
55
|
+
disabled?: boolean;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Additional class name for container
|
|
59
|
+
*/
|
|
60
|
+
className?: string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Modal title
|
|
64
|
+
*/
|
|
65
|
+
modalTitle?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Select<T = string>({
|
|
69
|
+
options,
|
|
70
|
+
value,
|
|
71
|
+
onChange,
|
|
72
|
+
placeholder = "Select an option",
|
|
73
|
+
label,
|
|
74
|
+
error,
|
|
75
|
+
disabled = false,
|
|
76
|
+
className,
|
|
77
|
+
modalTitle,
|
|
78
|
+
}: SelectProps<T>) {
|
|
79
|
+
const { isDark } = useTheme();
|
|
80
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
81
|
+
|
|
82
|
+
const selectedOption = options.find((opt) => opt.value === value);
|
|
83
|
+
|
|
84
|
+
const handleSelect = useCallback(
|
|
85
|
+
(option: SelectOption<T>) => {
|
|
86
|
+
if (option.disabled) return;
|
|
87
|
+
onChange?.(option.value);
|
|
88
|
+
setIsOpen(false);
|
|
89
|
+
},
|
|
90
|
+
[onChange]
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const renderOption = ({ item }: { item: SelectOption<T> }) => {
|
|
94
|
+
const isSelected = item.value === value;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<TouchableOpacity
|
|
98
|
+
onPress={() => handleSelect(item)}
|
|
99
|
+
disabled={item.disabled}
|
|
100
|
+
className={cn(
|
|
101
|
+
"flex-row items-center px-4 py-3 border-b",
|
|
102
|
+
isDark ? "border-surface-dark" : "border-gray-100",
|
|
103
|
+
isSelected && (isDark ? "bg-surface-dark" : "bg-primary-50"),
|
|
104
|
+
item.disabled && "opacity-50"
|
|
105
|
+
)}
|
|
106
|
+
activeOpacity={0.7}
|
|
107
|
+
>
|
|
108
|
+
{item.icon && (
|
|
109
|
+
<Ionicons
|
|
110
|
+
name={item.icon}
|
|
111
|
+
size={20}
|
|
112
|
+
color={isSelected ? "#10b981" : isDark ? "#94a3b8" : "#64748b"}
|
|
113
|
+
style={styles.optionIcon}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
<Text
|
|
117
|
+
className={cn(
|
|
118
|
+
"flex-1 text-base",
|
|
119
|
+
isSelected
|
|
120
|
+
? "text-primary-600 font-medium"
|
|
121
|
+
: isDark
|
|
122
|
+
? "text-text-dark"
|
|
123
|
+
: "text-text-light"
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
{item.label}
|
|
127
|
+
</Text>
|
|
128
|
+
{isSelected && <Ionicons name="checkmark" size={20} color="#10b981" />}
|
|
129
|
+
</TouchableOpacity>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<View className={cn("mb-4", className)}>
|
|
135
|
+
{label && (
|
|
136
|
+
<Text
|
|
137
|
+
className={cn(
|
|
138
|
+
"text-sm font-medium mb-1.5",
|
|
139
|
+
isDark ? "text-text-dark" : "text-text-light"
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{label}
|
|
143
|
+
</Text>
|
|
144
|
+
)}
|
|
145
|
+
|
|
146
|
+
<TouchableOpacity
|
|
147
|
+
onPress={() => !disabled && setIsOpen(true)}
|
|
148
|
+
disabled={disabled}
|
|
149
|
+
className={cn(
|
|
150
|
+
"flex-row items-center justify-between px-4 py-3 rounded-xl border",
|
|
151
|
+
isDark
|
|
152
|
+
? "bg-surface-dark border-gray-700"
|
|
153
|
+
: "bg-white border-gray-200",
|
|
154
|
+
error && "border-red-500",
|
|
155
|
+
disabled && "opacity-50"
|
|
156
|
+
)}
|
|
157
|
+
activeOpacity={0.7}
|
|
158
|
+
>
|
|
159
|
+
<Text
|
|
160
|
+
className={cn(
|
|
161
|
+
"text-base",
|
|
162
|
+
selectedOption
|
|
163
|
+
? isDark
|
|
164
|
+
? "text-text-dark"
|
|
165
|
+
: "text-text-light"
|
|
166
|
+
: isDark
|
|
167
|
+
? "text-muted-dark"
|
|
168
|
+
: "text-muted-light"
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
{selectedOption?.label || placeholder}
|
|
172
|
+
</Text>
|
|
173
|
+
<Ionicons
|
|
174
|
+
name="chevron-down"
|
|
175
|
+
size={20}
|
|
176
|
+
color={isDark ? "#94a3b8" : "#64748b"}
|
|
177
|
+
/>
|
|
178
|
+
</TouchableOpacity>
|
|
179
|
+
|
|
180
|
+
{error && <Text className="text-red-500 text-sm mt-1">{error}</Text>}
|
|
181
|
+
|
|
182
|
+
<Modal
|
|
183
|
+
visible={isOpen}
|
|
184
|
+
transparent
|
|
185
|
+
animationType="slide"
|
|
186
|
+
onRequestClose={() => setIsOpen(false)}
|
|
187
|
+
>
|
|
188
|
+
<View className="flex-1 justify-end bg-black/50">
|
|
189
|
+
<View
|
|
190
|
+
className={cn(
|
|
191
|
+
"rounded-t-3xl max-h-[70%]",
|
|
192
|
+
isDark ? "bg-background-dark" : "bg-white"
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
{/* Header */}
|
|
196
|
+
<View
|
|
197
|
+
className={cn(
|
|
198
|
+
"flex-row items-center justify-between px-4 py-4 border-b",
|
|
199
|
+
isDark ? "border-surface-dark" : "border-gray-100"
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
<Text
|
|
203
|
+
className={cn(
|
|
204
|
+
"text-lg font-semibold",
|
|
205
|
+
isDark ? "text-text-dark" : "text-text-light"
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
{modalTitle || label || "Select"}
|
|
209
|
+
</Text>
|
|
210
|
+
<TouchableOpacity
|
|
211
|
+
onPress={() => setIsOpen(false)}
|
|
212
|
+
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
213
|
+
>
|
|
214
|
+
<Ionicons
|
|
215
|
+
name="close"
|
|
216
|
+
size={24}
|
|
217
|
+
color={isDark ? "#f8fafc" : "#0f172a"}
|
|
218
|
+
/>
|
|
219
|
+
</TouchableOpacity>
|
|
220
|
+
</View>
|
|
221
|
+
|
|
222
|
+
{/* Options */}
|
|
223
|
+
<FlatList
|
|
224
|
+
data={options}
|
|
225
|
+
renderItem={renderOption}
|
|
226
|
+
keyExtractor={(item, index) => `${item.value}-${index}`}
|
|
227
|
+
showsVerticalScrollIndicator={false}
|
|
228
|
+
/>
|
|
229
|
+
</View>
|
|
230
|
+
</View>
|
|
231
|
+
</Modal>
|
|
232
|
+
</View>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const styles = StyleSheet.create({
|
|
237
|
+
optionIcon: {
|
|
238
|
+
marginRight: 12,
|
|
239
|
+
},
|
|
240
|
+
});
|