@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,261 @@
|
|
|
1
|
+
import { TouchableOpacity, View, Text } from "react-native";
|
|
2
|
+
import Animated, {
|
|
3
|
+
useAnimatedStyle,
|
|
4
|
+
withSpring,
|
|
5
|
+
withTiming,
|
|
6
|
+
interpolateColor,
|
|
7
|
+
} from "react-native-reanimated";
|
|
8
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
9
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
10
|
+
import { cn } from "@/utils/cn";
|
|
11
|
+
|
|
12
|
+
interface CheckboxProps {
|
|
13
|
+
/**
|
|
14
|
+
* Whether the checkbox is checked
|
|
15
|
+
*/
|
|
16
|
+
checked: boolean;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Callback when the checkbox is toggled
|
|
20
|
+
*/
|
|
21
|
+
onChange: (checked: boolean) => void;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Label text
|
|
25
|
+
*/
|
|
26
|
+
label?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Description text (below label)
|
|
30
|
+
*/
|
|
31
|
+
description?: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether the checkbox is disabled
|
|
35
|
+
*/
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Size variant
|
|
40
|
+
*/
|
|
41
|
+
size?: "sm" | "md" | "lg";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Additional class name
|
|
45
|
+
*/
|
|
46
|
+
className?: string;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Error message
|
|
50
|
+
*/
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const AnimatedView = Animated.createAnimatedComponent(View);
|
|
55
|
+
|
|
56
|
+
const sizes = {
|
|
57
|
+
sm: { box: 18, icon: 12, label: "text-sm" },
|
|
58
|
+
md: { box: 22, icon: 16, label: "text-base" },
|
|
59
|
+
lg: { box: 26, icon: 20, label: "text-lg" },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function Checkbox({
|
|
63
|
+
checked,
|
|
64
|
+
onChange,
|
|
65
|
+
label,
|
|
66
|
+
description,
|
|
67
|
+
disabled = false,
|
|
68
|
+
size = "md",
|
|
69
|
+
className,
|
|
70
|
+
error,
|
|
71
|
+
}: CheckboxProps) {
|
|
72
|
+
const { isDark } = useTheme();
|
|
73
|
+
const sizeConfig = sizes[size];
|
|
74
|
+
|
|
75
|
+
const animatedBoxStyle = useAnimatedStyle(() => {
|
|
76
|
+
const backgroundColor = interpolateColor(
|
|
77
|
+
checked ? 1 : 0,
|
|
78
|
+
[0, 1],
|
|
79
|
+
["transparent", "#10b981"]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const borderColor = interpolateColor(
|
|
83
|
+
checked ? 1 : 0,
|
|
84
|
+
[0, 1],
|
|
85
|
+
[error ? "#ef4444" : isDark ? "#475569" : "#cbd5e1", "#10b981"]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
backgroundColor: withTiming(backgroundColor, { duration: 150 }),
|
|
90
|
+
borderColor: withTiming(borderColor, { duration: 150 }),
|
|
91
|
+
transform: [
|
|
92
|
+
{
|
|
93
|
+
scale: withSpring(checked ? 1 : 0.95, {
|
|
94
|
+
damping: 15,
|
|
95
|
+
stiffness: 400,
|
|
96
|
+
}),
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}, [checked, isDark, error]);
|
|
101
|
+
|
|
102
|
+
const animatedCheckStyle = useAnimatedStyle(() => {
|
|
103
|
+
return {
|
|
104
|
+
opacity: withTiming(checked ? 1 : 0, { duration: 150 }),
|
|
105
|
+
transform: [
|
|
106
|
+
{
|
|
107
|
+
scale: withSpring(checked ? 1 : 0.5, {
|
|
108
|
+
damping: 15,
|
|
109
|
+
stiffness: 400,
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}, [checked]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<TouchableOpacity
|
|
118
|
+
onPress={() => !disabled && onChange(!checked)}
|
|
119
|
+
disabled={disabled}
|
|
120
|
+
activeOpacity={0.7}
|
|
121
|
+
className={cn(
|
|
122
|
+
"flex-row items-start",
|
|
123
|
+
disabled && "opacity-50",
|
|
124
|
+
className
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
<AnimatedView
|
|
128
|
+
style={[
|
|
129
|
+
animatedBoxStyle,
|
|
130
|
+
{
|
|
131
|
+
width: sizeConfig.box,
|
|
132
|
+
height: sizeConfig.box,
|
|
133
|
+
borderWidth: 2,
|
|
134
|
+
borderRadius: 6,
|
|
135
|
+
justifyContent: "center",
|
|
136
|
+
alignItems: "center",
|
|
137
|
+
marginTop: 2,
|
|
138
|
+
},
|
|
139
|
+
]}
|
|
140
|
+
>
|
|
141
|
+
<Animated.View style={animatedCheckStyle}>
|
|
142
|
+
<Ionicons name="checkmark" size={sizeConfig.icon} color="white" />
|
|
143
|
+
</Animated.View>
|
|
144
|
+
</AnimatedView>
|
|
145
|
+
|
|
146
|
+
{(label || description) && (
|
|
147
|
+
<View className="flex-1 ml-3">
|
|
148
|
+
{label && (
|
|
149
|
+
<Text
|
|
150
|
+
className={cn(
|
|
151
|
+
sizeConfig.label,
|
|
152
|
+
isDark ? "text-text-dark" : "text-text-light",
|
|
153
|
+
disabled && "opacity-70"
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
{label}
|
|
157
|
+
</Text>
|
|
158
|
+
)}
|
|
159
|
+
{description && (
|
|
160
|
+
<Text
|
|
161
|
+
className={cn(
|
|
162
|
+
"text-sm mt-0.5",
|
|
163
|
+
isDark ? "text-muted-dark" : "text-muted-light"
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{description}
|
|
167
|
+
</Text>
|
|
168
|
+
)}
|
|
169
|
+
{error && <Text className="text-red-500 text-sm mt-1">{error}</Text>}
|
|
170
|
+
</View>
|
|
171
|
+
)}
|
|
172
|
+
</TouchableOpacity>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Checkbox Group component for multiple checkboxes
|
|
178
|
+
*/
|
|
179
|
+
interface CheckboxGroupProps<T extends string> {
|
|
180
|
+
/**
|
|
181
|
+
* Available options
|
|
182
|
+
*/
|
|
183
|
+
options: {
|
|
184
|
+
value: T;
|
|
185
|
+
label: string;
|
|
186
|
+
description?: string;
|
|
187
|
+
disabled?: boolean;
|
|
188
|
+
}[];
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Currently selected values
|
|
192
|
+
*/
|
|
193
|
+
value: T[];
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Callback when selection changes
|
|
197
|
+
*/
|
|
198
|
+
onChange: (value: T[]) => void;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Group label
|
|
202
|
+
*/
|
|
203
|
+
label?: string;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Size variant
|
|
207
|
+
*/
|
|
208
|
+
size?: "sm" | "md" | "lg";
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Additional class name
|
|
212
|
+
*/
|
|
213
|
+
className?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function CheckboxGroup<T extends string>({
|
|
217
|
+
options,
|
|
218
|
+
value,
|
|
219
|
+
onChange,
|
|
220
|
+
label,
|
|
221
|
+
size = "md",
|
|
222
|
+
className,
|
|
223
|
+
}: CheckboxGroupProps<T>) {
|
|
224
|
+
const { isDark } = useTheme();
|
|
225
|
+
|
|
226
|
+
const handleToggle = (optionValue: T) => {
|
|
227
|
+
if (value.includes(optionValue)) {
|
|
228
|
+
onChange(value.filter((v) => v !== optionValue));
|
|
229
|
+
} else {
|
|
230
|
+
onChange([...value, optionValue]);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<View className={className}>
|
|
236
|
+
{label && (
|
|
237
|
+
<Text
|
|
238
|
+
className={cn(
|
|
239
|
+
"text-sm font-medium mb-3",
|
|
240
|
+
isDark ? "text-text-dark" : "text-text-light"
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{label}
|
|
244
|
+
</Text>
|
|
245
|
+
)}
|
|
246
|
+
<View className="gap-3">
|
|
247
|
+
{options.map((option) => (
|
|
248
|
+
<Checkbox
|
|
249
|
+
key={option.value}
|
|
250
|
+
checked={value.includes(option.value)}
|
|
251
|
+
onChange={() => handleToggle(option.value)}
|
|
252
|
+
label={option.label}
|
|
253
|
+
description={option.description}
|
|
254
|
+
disabled={option.disabled}
|
|
255
|
+
size={size}
|
|
256
|
+
/>
|
|
257
|
+
))}
|
|
258
|
+
</View>
|
|
259
|
+
</View>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Input } from "./Input";
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Input> = {
|
|
6
|
+
title: "UI/Input",
|
|
7
|
+
component: Input,
|
|
8
|
+
argTypes: {
|
|
9
|
+
label: {
|
|
10
|
+
control: "text",
|
|
11
|
+
},
|
|
12
|
+
placeholder: {
|
|
13
|
+
control: "text",
|
|
14
|
+
},
|
|
15
|
+
error: {
|
|
16
|
+
control: "text",
|
|
17
|
+
},
|
|
18
|
+
hint: {
|
|
19
|
+
control: "text",
|
|
20
|
+
},
|
|
21
|
+
secureTextEntry: {
|
|
22
|
+
control: "boolean",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
label: "Label",
|
|
27
|
+
placeholder: "Enter text...",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default meta;
|
|
32
|
+
type Story = StoryObj<typeof Input>;
|
|
33
|
+
|
|
34
|
+
export const Default: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
label: "Email",
|
|
37
|
+
placeholder: "Enter your email",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const WithIcon: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
label: "Search",
|
|
44
|
+
placeholder: "Search...",
|
|
45
|
+
leftIcon: "search",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const Password: Story = {
|
|
50
|
+
args: {
|
|
51
|
+
label: "Password",
|
|
52
|
+
placeholder: "Enter password",
|
|
53
|
+
secureTextEntry: true,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const WithError: Story = {
|
|
58
|
+
args: {
|
|
59
|
+
label: "Email",
|
|
60
|
+
placeholder: "Enter your email",
|
|
61
|
+
value: "invalid-email",
|
|
62
|
+
error: "Please enter a valid email address",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const WithHint: Story = {
|
|
67
|
+
args: {
|
|
68
|
+
label: "Username",
|
|
69
|
+
placeholder: "Choose a username",
|
|
70
|
+
hint: "Username must be 3-20 characters",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const AllStates: Story = {
|
|
75
|
+
render: () => (
|
|
76
|
+
<View style={{ gap: 16 }}>
|
|
77
|
+
<Input label="Default" placeholder="Enter text..." />
|
|
78
|
+
<Input
|
|
79
|
+
label="With Value"
|
|
80
|
+
placeholder="Enter text..."
|
|
81
|
+
value="Hello World"
|
|
82
|
+
/>
|
|
83
|
+
<Input
|
|
84
|
+
label="With Error"
|
|
85
|
+
placeholder="Enter text..."
|
|
86
|
+
value="Invalid"
|
|
87
|
+
error="This field has an error"
|
|
88
|
+
/>
|
|
89
|
+
<Input
|
|
90
|
+
label="With Hint"
|
|
91
|
+
placeholder="Enter text..."
|
|
92
|
+
hint="This is a helpful hint"
|
|
93
|
+
/>
|
|
94
|
+
<Input
|
|
95
|
+
label="Password"
|
|
96
|
+
placeholder="Enter password"
|
|
97
|
+
secureTextEntry
|
|
98
|
+
/>
|
|
99
|
+
<Input
|
|
100
|
+
label="With Icon"
|
|
101
|
+
placeholder="Search..."
|
|
102
|
+
leftIcon="search"
|
|
103
|
+
/>
|
|
104
|
+
</View>
|
|
105
|
+
),
|
|
106
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { forwardRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
TextInput,
|
|
6
|
+
TextInputProps,
|
|
7
|
+
Pressable,
|
|
8
|
+
} from "react-native";
|
|
9
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
10
|
+
import { cn } from "@/utils/cn";
|
|
11
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
12
|
+
|
|
13
|
+
interface InputProps extends TextInputProps {
|
|
14
|
+
label?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
hint?: string;
|
|
17
|
+
leftIcon?: keyof typeof Ionicons.glyphMap;
|
|
18
|
+
rightIcon?: keyof typeof Ionicons.glyphMap;
|
|
19
|
+
onRightIconPress?: () => void;
|
|
20
|
+
containerClassName?: string;
|
|
21
|
+
inputClassName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Input = forwardRef<TextInput, InputProps>(
|
|
25
|
+
(
|
|
26
|
+
{
|
|
27
|
+
label,
|
|
28
|
+
error,
|
|
29
|
+
hint,
|
|
30
|
+
leftIcon,
|
|
31
|
+
rightIcon,
|
|
32
|
+
onRightIconPress,
|
|
33
|
+
containerClassName,
|
|
34
|
+
inputClassName,
|
|
35
|
+
secureTextEntry,
|
|
36
|
+
...props
|
|
37
|
+
},
|
|
38
|
+
ref
|
|
39
|
+
) => {
|
|
40
|
+
const { isDark } = useTheme();
|
|
41
|
+
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
|
42
|
+
const isPassword = secureTextEntry !== undefined;
|
|
43
|
+
|
|
44
|
+
const togglePasswordVisibility = () => {
|
|
45
|
+
setIsPasswordVisible(!isPasswordVisible);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<View className={cn("w-full", containerClassName)}>
|
|
50
|
+
{label && (
|
|
51
|
+
<Text className="mb-2 text-sm font-medium text-text-light dark:text-text-dark">
|
|
52
|
+
{label}
|
|
53
|
+
</Text>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<View
|
|
57
|
+
className={cn(
|
|
58
|
+
"flex-row items-center rounded-xl border-2 bg-surface-light px-4 dark:bg-surface-dark",
|
|
59
|
+
error
|
|
60
|
+
? "border-red-500"
|
|
61
|
+
: "border-gray-200 focus-within:border-primary-500 dark:border-gray-700"
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{leftIcon && (
|
|
65
|
+
<Ionicons
|
|
66
|
+
name={leftIcon}
|
|
67
|
+
size={20}
|
|
68
|
+
color={isDark ? "#94a3b8" : "#64748b"}
|
|
69
|
+
style={{ marginRight: 8 }}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
<TextInput
|
|
74
|
+
ref={ref}
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex-1 py-3 text-base text-text-light dark:text-text-dark",
|
|
77
|
+
inputClassName
|
|
78
|
+
)}
|
|
79
|
+
placeholderTextColor={isDark ? "#64748b" : "#94a3b8"}
|
|
80
|
+
secureTextEntry={isPassword && !isPasswordVisible}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
|
|
84
|
+
{isPassword ? (
|
|
85
|
+
<Pressable onPress={togglePasswordVisibility}>
|
|
86
|
+
<Ionicons
|
|
87
|
+
name={isPasswordVisible ? "eye-off-outline" : "eye-outline"}
|
|
88
|
+
size={20}
|
|
89
|
+
color={isDark ? "#94a3b8" : "#64748b"}
|
|
90
|
+
/>
|
|
91
|
+
</Pressable>
|
|
92
|
+
) : rightIcon ? (
|
|
93
|
+
<Pressable onPress={onRightIconPress} disabled={!onRightIconPress}>
|
|
94
|
+
<Ionicons
|
|
95
|
+
name={rightIcon}
|
|
96
|
+
size={20}
|
|
97
|
+
color={isDark ? "#94a3b8" : "#64748b"}
|
|
98
|
+
/>
|
|
99
|
+
</Pressable>
|
|
100
|
+
) : null}
|
|
101
|
+
</View>
|
|
102
|
+
|
|
103
|
+
{error && (
|
|
104
|
+
<Text className="mt-1 text-sm text-red-500">{error}</Text>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{hint && !error && (
|
|
108
|
+
<Text className="mt-1 text-sm text-muted-light dark:text-muted-dark">
|
|
109
|
+
{hint}
|
|
110
|
+
</Text>
|
|
111
|
+
)}
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
Input.displayName = "Input";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Modal as RNModal,
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Pressable,
|
|
6
|
+
ModalProps as RNModalProps,
|
|
7
|
+
KeyboardAvoidingView,
|
|
8
|
+
Platform,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import { Ionicons } from "@expo/vector-icons";
|
|
11
|
+
import { cn } from "@/utils/cn";
|
|
12
|
+
import { useTheme } from "@/hooks/useTheme";
|
|
13
|
+
|
|
14
|
+
interface ModalProps extends Omit<RNModalProps, "children"> {
|
|
15
|
+
title?: string;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
showCloseButton?: boolean;
|
|
18
|
+
size?: "sm" | "md" | "lg" | "full";
|
|
19
|
+
className?: string;
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sizeStyles = {
|
|
24
|
+
sm: "max-w-sm",
|
|
25
|
+
md: "max-w-md",
|
|
26
|
+
lg: "max-w-lg",
|
|
27
|
+
full: "w-full h-full",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function Modal({
|
|
31
|
+
visible,
|
|
32
|
+
title,
|
|
33
|
+
onClose,
|
|
34
|
+
showCloseButton = true,
|
|
35
|
+
size = "md",
|
|
36
|
+
className,
|
|
37
|
+
children,
|
|
38
|
+
...props
|
|
39
|
+
}: ModalProps) {
|
|
40
|
+
const { isDark } = useTheme();
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<RNModal
|
|
44
|
+
visible={visible}
|
|
45
|
+
transparent
|
|
46
|
+
animationType="fade"
|
|
47
|
+
onRequestClose={onClose}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
<KeyboardAvoidingView
|
|
51
|
+
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
52
|
+
className="flex-1"
|
|
53
|
+
>
|
|
54
|
+
<Pressable
|
|
55
|
+
onPress={onClose}
|
|
56
|
+
className="flex-1 items-center justify-center bg-black/50 px-4"
|
|
57
|
+
>
|
|
58
|
+
<Pressable
|
|
59
|
+
onPress={(e) => e.stopPropagation()}
|
|
60
|
+
className={cn(
|
|
61
|
+
"w-full rounded-2xl bg-background-light p-6 dark:bg-background-dark",
|
|
62
|
+
size !== "full" && sizeStyles[size],
|
|
63
|
+
className
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
{/* Header */}
|
|
67
|
+
{(title || showCloseButton) && (
|
|
68
|
+
<View className="mb-4 flex-row items-center justify-between">
|
|
69
|
+
{title ? (
|
|
70
|
+
<Text className="text-xl font-semibold text-text-light dark:text-text-dark">
|
|
71
|
+
{title}
|
|
72
|
+
</Text>
|
|
73
|
+
) : (
|
|
74
|
+
<View />
|
|
75
|
+
)}
|
|
76
|
+
{showCloseButton && (
|
|
77
|
+
<Pressable
|
|
78
|
+
onPress={onClose}
|
|
79
|
+
className="h-8 w-8 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800"
|
|
80
|
+
>
|
|
81
|
+
<Ionicons
|
|
82
|
+
name="close"
|
|
83
|
+
size={20}
|
|
84
|
+
color={isDark ? "#f8fafc" : "#0f172a"}
|
|
85
|
+
/>
|
|
86
|
+
</Pressable>
|
|
87
|
+
)}
|
|
88
|
+
</View>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{/* Content */}
|
|
92
|
+
{children}
|
|
93
|
+
</Pressable>
|
|
94
|
+
</Pressable>
|
|
95
|
+
</KeyboardAvoidingView>
|
|
96
|
+
</RNModal>
|
|
97
|
+
);
|
|
98
|
+
}
|