@towsila/expo-ui-library 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.
Files changed (104) hide show
  1. package/README.md +142 -0
  2. package/package.json +55 -0
  3. package/src/assets/customIcons/arrow.svg +3 -0
  4. package/src/assets/customIcons/arrow_back.svg +3 -0
  5. package/src/assets/customIcons/ban.svg +4 -0
  6. package/src/assets/customIcons/checkmark.svg +4 -0
  7. package/src/assets/customIcons/close.svg +5 -0
  8. package/src/assets/customIcons/dark_mode.svg +3 -0
  9. package/src/assets/customIcons/dark_suspended.svg +67 -0
  10. package/src/assets/customIcons/document.svg +4 -0
  11. package/src/assets/customIcons/email.svg +3 -0
  12. package/src/assets/customIcons/eye.svg +5 -0
  13. package/src/assets/customIcons/eye_off.svg +7 -0
  14. package/src/assets/customIcons/humberger.svg +5 -0
  15. package/src/assets/customIcons/information.svg +6 -0
  16. package/src/assets/customIcons/light_mode.svg +18 -0
  17. package/src/assets/customIcons/light_suspended.svg +67 -0
  18. package/src/assets/customIcons/lock_closed.svg +4 -0
  19. package/src/assets/customIcons/logout.svg +4 -0
  20. package/src/assets/customIcons/person.svg +4 -0
  21. package/src/assets/customIcons/phone.svg +3 -0
  22. package/src/assets/customIcons/phone_dark.svg +36 -0
  23. package/src/assets/customIcons/phone_light.svg +36 -0
  24. package/src/assets/customIcons/shield.svg +4 -0
  25. package/src/assets/customIcons/trash.svg +9 -0
  26. package/src/assets/customIcons/warning.svg +5 -0
  27. package/src/assets/icons/google.svg +9 -0
  28. package/src/assets/images/bg_dark.svg +5 -0
  29. package/src/assets/images/bg_dark_v1.svg +5 -0
  30. package/src/assets/images/bg_light.svg +5 -0
  31. package/src/assets/images/bg_light_v1.svg +5 -0
  32. package/src/assets/images/en.jpg +0 -0
  33. package/src/assets/images/fr.jpg +0 -0
  34. package/src/components/icons/ban.tsx +77 -0
  35. package/src/components/icons/chatIcon.tsx +19 -0
  36. package/src/components/icons/homeIcon.tsx +25 -0
  37. package/src/components/icons/profileIcon.tsx +29 -0
  38. package/src/components/icons/settingsIcon.tsx +22 -0
  39. package/src/components/reusable/templatePage.tsx +94 -0
  40. package/src/components/reusable/templateSubPage.tsx +66 -0
  41. package/src/components/screens/AccountStatusScreen.tsx +102 -0
  42. package/src/components/screens/AddReportScreen.tsx +135 -0
  43. package/src/components/screens/LegalScreen.tsx +41 -0
  44. package/src/components/screens/VerificationCodeScreen.tsx +66 -0
  45. package/src/components/screens/aboutScreen.tsx +139 -0
  46. package/src/components/screens/forgotPasswordScreen.tsx +60 -0
  47. package/src/components/screens/loginScreen.tsx +165 -0
  48. package/src/components/screens/onBoarding.tsx +178 -0
  49. package/src/components/screens/resetPassword.tsx +115 -0
  50. package/src/components/screens/settingScreen.tsx +85 -0
  51. package/src/components/screens/signUpScreen.tsx +119 -0
  52. package/src/components/screens/splashScreen.tsx +170 -0
  53. package/src/components/ui/OTPInput.tsx +73 -0
  54. package/src/components/ui/PageTopBar.tsx +41 -0
  55. package/src/components/ui/asidebar.tsx +151 -0
  56. package/src/components/ui/badge.tsx +21 -0
  57. package/src/components/ui/button.tsx +58 -0
  58. package/src/components/ui/customPopup.tsx +176 -0
  59. package/src/components/ui/dynamicForm.tsx +144 -0
  60. package/src/components/ui/error.tsx +23 -0
  61. package/src/components/ui/input.tsx +121 -0
  62. package/src/components/ui/languageSelect.tsx +99 -0
  63. package/src/components/ui/pageIndicator.tsx +58 -0
  64. package/src/components/ui/passwordRequirmentCard.tsx +55 -0
  65. package/src/components/ui/sharpButton.tsx +67 -0
  66. package/src/components/ui/sharpContainer.tsx +180 -0
  67. package/src/components/ui/sharpDynamicForm.tsx +148 -0
  68. package/src/components/ui/sharpInput.tsx +148 -0
  69. package/src/components/ui/sharpOtpInput.tsx +228 -0
  70. package/src/components/ui/sharpSelect.tsx +155 -0
  71. package/src/components/ui/successDialog.tsx +97 -0
  72. package/src/components/ui/termsPopup.tsx +56 -0
  73. package/src/components/ui/themeToggle.tsx +98 -0
  74. package/src/components/ui/toast.tsx +163 -0
  75. package/src/components/ui/warningModel.tsx +132 -0
  76. package/src/constants/language.ts +15 -0
  77. package/src/context/LanguageContext.tsx +49 -0
  78. package/src/context/LocationPermissionProvider.tsx +106 -0
  79. package/src/context/ThemeContext.tsx +70 -0
  80. package/src/hooks/useCountdown.ts +21 -0
  81. package/src/hooks/useForgotPasswordForm.ts +46 -0
  82. package/src/hooks/useForm.ts +52 -0
  83. package/src/hooks/useLoginForm.ts +25 -0
  84. package/src/hooks/useResetPasswordForm.ts +75 -0
  85. package/src/hooks/useSignup.tsx +34 -0
  86. package/src/hooks/useSubmitReport.ts +47 -0
  87. package/src/hooks/useUpdateUser.tsx +81 -0
  88. package/src/icons/index.ts +36 -0
  89. package/src/index.ts +62 -0
  90. package/src/service/createApi.ts +57 -0
  91. package/src/service/sendEmail.ts +11 -0
  92. package/src/tokens/themes.ts +19 -0
  93. package/src/tokens/typography.ts +16 -0
  94. package/src/types/formFields.ts +13 -0
  95. package/src/types/icon.ts +6 -0
  96. package/src/types/language.ts +7 -0
  97. package/src/types/menu.ts +9 -0
  98. package/src/types/settingsFields.tsx +9 -0
  99. package/src/types/svg.d.ts +6 -0
  100. package/src/types/validateForm.ts +8 -0
  101. package/src/utils/helpers/groupSettings.ts +21 -0
  102. package/src/utils/inputValidator/customValidator.ts +36 -0
  103. package/src/utils/inputValidator/generalTerms.ts +19 -0
  104. package/src/utils/inputValidator/registerValidator.ts +31 -0
@@ -0,0 +1,148 @@
1
+ import { useState } from "react";
2
+ import { ScrollView, Text, TouchableOpacity, View } from "react-native";
3
+ import Input from "./input";
4
+ import { Field } from "../../types/formFields";
5
+ import { useAppTheme } from "../../context/ThemeContext";
6
+ import { validateForm } from "../../utils/inputValidator/registerValidator";
7
+ import { Ionicons } from "@expo/vector-icons";
8
+ import { Fonts, textSize } from "../../tokens/typography";
9
+ import SharpInput from "./sharpInput";
10
+ import Arrow from "../../assets/customIcons/arrow.svg";
11
+ import ArrowBack from "../../assets/customIcons/arrow_back.svg";
12
+ import EyeIcon from "../../assets/customIcons/eye.svg";
13
+ import EyeOffIcon from "../../assets/customIcons/eye_off.svg"
14
+ interface Props {
15
+ fields: Field[];
16
+ formState: Record<string, string>;
17
+ setFormState: React.Dispatch<React.SetStateAction<Record<string, string>>>;
18
+ errors: Record<string, string>;
19
+ setErrors: React.Dispatch<React.SetStateAction<Record<string, string>>>;
20
+ clearError: (name: string) => void;
21
+ onStepChange?: (step: number, total: number) => void;
22
+ nextLabel?: string;
23
+ backLabel?: string;
24
+ }
25
+ // can i split it into 2 not only move the password if the number of fields is > 4
26
+ function splitIntoSteps(fields: Field[]): Field[][] {
27
+ const info = fields.filter((f) => f.type !== "password");
28
+ const security = fields.filter((f) => f.type === "password");
29
+ return [info, security].filter((s) => s.length > 0);
30
+ }
31
+
32
+ export const SharpDynamicForm: React.FC<Props> = ({
33
+ fields,
34
+ formState,
35
+ setFormState,
36
+ errors,
37
+ setErrors,
38
+ clearError,
39
+ onStepChange,
40
+ nextLabel,
41
+ backLabel,
42
+ }) => {
43
+ const [showPasswordField, setShowPasswordField] = useState<Record<string, boolean>>({});
44
+ const [currentStep, setCurrentStep] = useState(0);
45
+ const { colors } = useAppTheme();
46
+
47
+ const steps = splitIntoSteps(fields);
48
+ const totalSteps = steps.length;
49
+ const stepFields = steps[currentStep] ?? [];
50
+
51
+ const goNext = () => {
52
+ // validate current step fields before proceeding
53
+ const newErrors = validateForm(stepFields, formState);
54
+ setErrors((prev) => ({ ...prev, ...newErrors }));
55
+ if (Object.keys(newErrors).length > 0) return;
56
+
57
+ const next = Math.min(currentStep + 1, totalSteps - 1);
58
+ setCurrentStep(next);
59
+ onStepChange?.(next, totalSteps);
60
+ };
61
+
62
+ const goPrev = () => {
63
+ const prev = Math.max(currentStep - 1, 0);
64
+ setCurrentStep(prev);
65
+ onStepChange?.(prev, totalSteps);
66
+ };
67
+
68
+ const renderInput = (field: Field) => (
69
+ <View key={field.name} style={{ marginBottom: 10 }}>
70
+ <SharpInput
71
+ value={formState[field.name] || ""}
72
+ onChangeText={(text) => setFormState((prev) => ({ ...prev, [field.name]: text }))}
73
+ iconLeft={field.svgIcon}
74
+ iconRight={
75
+ field.secureTextEntry
76
+ ? (color) => showPasswordField[field.name]
77
+ ? <EyeOffIcon width={18} height={18} color={color} />
78
+ : <EyeIcon width={18} height={18} color={color} />
79
+ : undefined
80
+ }
81
+ placeholder={field.placeholder}
82
+ onPressIconRight={
83
+ field.secureTextEntry
84
+ ? () => setShowPasswordField((prev) => ({ ...prev, [field.name]: !prev[field.name] }))
85
+ : undefined
86
+ }
87
+ secureTextEntry={field.secureTextEntry && !showPasswordField[field.name]}
88
+ returnKeyType="next"
89
+ error={errors[field.name]}
90
+ onClearError={() => clearError(field.name)}
91
+ label={field.label}
92
+ />
93
+ </View>
94
+ );
95
+
96
+ return (
97
+ <View style={{ gap: 4 }}>
98
+
99
+ {/* Stepper — only show if more than 1 step */}
100
+ {totalSteps > 1 && (
101
+ <View style={{ flexDirection: "row", alignItems: "center", marginBottom: 12 }}>
102
+ {/* Back arrow */}
103
+ <View style={{ flex: 1 }}>
104
+ {currentStep > 0 && backLabel && (
105
+ <TouchableOpacity onPress={goPrev} style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-start", gap: 6 }}>
106
+ <ArrowBack width={16} height={16} color={colors.text.secondary}/>
107
+ <Text style={{ color: colors.text.secondary, fontFamily: Fonts.montserrat.semiBold, fontSize: textSize.sm }}>
108
+ {backLabel}
109
+ </Text>
110
+ </TouchableOpacity>
111
+ )}
112
+ </View>
113
+
114
+ {/* Pills */}
115
+ <View style={{ flexDirection: "row", gap: 8 }}>
116
+ {steps.map((_, i) => (
117
+ <View key={i} style={{
118
+ width: i === currentStep ? 28 : 10,
119
+ height: 6,
120
+ borderRadius: 3,
121
+ backgroundColor: i <= currentStep ? colors.primary : colors.text.placeholder + "40",
122
+ }} />
123
+ ))}
124
+ </View>
125
+ <View style={{ flex: 1 }} />
126
+ </View>
127
+ )}
128
+
129
+ {/* Fields */}
130
+ {stepFields.map((f) => renderInput(f))}
131
+
132
+ {/* Navigation */}
133
+ {totalSteps > 1 && (
134
+ <View style={{ gap: 10, marginTop: 4 }}>
135
+ {currentStep < totalSteps - 1 && (
136
+ <TouchableOpacity onPress={goNext} style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 6 }}>
137
+ <Text style={{ color: colors.text.secondary, fontFamily: Fonts.montserrat.semiBold, fontSize: textSize.sm }}>
138
+ {nextLabel}
139
+ </Text>
140
+ <Arrow width={16} height={16} color={colors.text.secondary}/>
141
+ </TouchableOpacity>
142
+ )}
143
+
144
+ </View>
145
+ )}
146
+ </View>
147
+ );
148
+ };
@@ -0,0 +1,148 @@
1
+ import React, { useState, ComponentProps } from "react";
2
+ import { Text, TextInput, TextInputProps, View, StyleSheet, TouchableOpacity } from "react-native";
3
+ import { useAppTheme } from "../../context/ThemeContext";
4
+ import { textSize, Fonts } from "../../tokens/typography";
5
+ import ErrorMessage from "./error";
6
+ import SharpContainer from "./sharpContainer";
7
+ interface InputProps extends TextInputProps {
8
+ value: string;
9
+ onChangeText: (text: string) => void;
10
+ placeholder?: string;
11
+ iconLeft?: (color: string)=>React.ReactNode;
12
+ iconRight?:(color: string)=>React.ReactNode;
13
+ onPressIconRight?: () => void;
14
+ error?: string;
15
+ label?: string;
16
+ onClearError?: () => void;
17
+ height?: number;
18
+ prefix?: React.ReactNode;
19
+ bgColor?:string;
20
+ }
21
+
22
+ export default function SharpInput({
23
+ value,
24
+ onChangeText,
25
+ placeholder,
26
+ iconLeft,
27
+ iconRight,
28
+ onPressIconRight,
29
+ error,
30
+ label,
31
+ onClearError,
32
+ height = 50,
33
+ prefix,
34
+ bgColor,
35
+ ...rest
36
+ }: InputProps) {
37
+ const { colors } = useAppTheme();
38
+ const [focused, setFocused] = useState(false);
39
+
40
+ // Determine border color based on state
41
+ const getBorderColor = () => {
42
+ if (error) return colors.semantic.error;
43
+ if (focused) return colors.primary;
44
+ return colors.semantic.border; // default border color
45
+ };
46
+
47
+ return (
48
+ <View style={{ marginBottom: 4 }}>
49
+ {label && (
50
+ <Text
51
+ style={{
52
+ color: colors.text.primary,
53
+ fontSize: textSize.xs,
54
+ fontFamily: Fonts.montserrat.semiBold,
55
+ marginBottom: 6,
56
+ }}
57
+ >
58
+ {label}
59
+ </Text>
60
+ )}
61
+
62
+ {/* Sharp container wraps the input + icons */}
63
+ <SharpContainer
64
+ height={height}
65
+ bgColor={bgColor ?? colors.background.main}
66
+ borderColor={getBorderColor()} // dynamic border
67
+
68
+ >
69
+ <View style={styles.inputWrapper}>
70
+ {iconLeft && (
71
+ <View style={styles.iconLeft}>
72
+ {iconLeft(getBorderColor())}
73
+ </View>
74
+ )}
75
+ {prefix && (
76
+ <View style={styles.prefix}>
77
+ {prefix}
78
+ </View>
79
+ )}
80
+
81
+ <TextInput
82
+ value={value}
83
+ onChangeText={(text) => {
84
+ onChangeText(text);
85
+ if (error) onClearError?.();
86
+ }}
87
+ placeholder={placeholder}
88
+ placeholderTextColor={colors.text.secondary}
89
+ onFocus={() => {
90
+ setFocused(true);
91
+ if (error) onClearError?.();
92
+ }}
93
+ onBlur={() => setFocused(false)}
94
+ style={[
95
+ styles.input,
96
+ {
97
+ color: colors.text.primary,
98
+ paddingLeft: iconLeft ? 42 : 16,
99
+ paddingRight: iconRight ? 42 : 16,
100
+ backgroundColor: "transparent", // let SharpContainer handle background
101
+ },
102
+ ]}
103
+ {...rest}
104
+ />
105
+
106
+ {iconRight && (
107
+ <TouchableOpacity onPress={onPressIconRight} style={styles.iconRight}>
108
+ {iconRight(colors.semantic.border)}
109
+ </TouchableOpacity>
110
+ )}
111
+ </View>
112
+ </SharpContainer>
113
+
114
+ {error && <ErrorMessage message={error} />}
115
+ </View>
116
+ );
117
+ }
118
+
119
+ const styles = StyleSheet.create({
120
+ inputWrapper: {
121
+ flex: 1,
122
+ flexDirection: "row",
123
+ alignItems: "center",
124
+ paddingHorizontal: 4, // small inner padding
125
+ },
126
+ iconLeft: {
127
+ position: "absolute",
128
+ left: 14,
129
+ zIndex: 1,
130
+ },
131
+ iconRight: {
132
+ position: "absolute",
133
+ right: 14,
134
+ zIndex: 1,
135
+ },
136
+ input: {
137
+ flex: 1,
138
+ height: "100%",
139
+ fontSize: textSize.sm,
140
+ fontFamily: Fonts.montserrat.regular,
141
+ // No background, no border – SharpContainer provides shape
142
+ },
143
+ prefix:{
144
+ flexDirection: "row",
145
+ gap: 4,
146
+ alignItems:"center"
147
+ }
148
+ });
@@ -0,0 +1,228 @@
1
+ import { useRef, useState } from "react";
2
+ import { View, TextInput, StyleSheet } from "react-native";
3
+ import Svg, { Path, Defs, LinearGradient as SvgGradient, Stop } from "react-native-svg";
4
+ import { useAppTheme } from "../../context/ThemeContext";
5
+ import { textSize, Fonts } from "../../tokens/typography";
6
+
7
+ interface OtpInputProps {
8
+ length?: number;
9
+ onComplete?: (code: string) => void;
10
+ onChange?: (code: string) => void;
11
+ bgColor?: string;
12
+ }
13
+
14
+ const BOX = 70;
15
+
16
+ function SharpBox({ bgColor, borderColor, id }: { bgColor: string; borderColor: string; id: string }) {
17
+ // match SharpElement exactly — W is dims.w - 8, H is dims.h
18
+ const W = BOX - 8;
19
+ const H = BOX;
20
+
21
+ const skewX = 10;
22
+ const skewYT = H * 0.05;
23
+ const skewYB = H * 0.95;
24
+ const crT = H * 0.25;
25
+ const crB = H * 0.75;
26
+
27
+ const bg = `
28
+ M ${skewX},0
29
+ L ${W - skewX},${skewYT}
30
+ L ${W},${crT}
31
+ L ${W},${crB}
32
+ L ${W - skewX},${skewYB}
33
+ L ${skewX},${H}
34
+ L 0,${crB}
35
+ L 0,${crT}
36
+ Z
37
+ `;
38
+
39
+ return (
40
+ <Svg
41
+ width="100%"
42
+ height="100%"
43
+ viewBox={`0 0 ${W} ${H}`}
44
+ style={StyleSheet.absoluteFill}
45
+ >
46
+ <Defs>
47
+ <SvgGradient id={`btnGrad_${id}`} x1="0%" y1="0%" x2="100%" y2="0%">
48
+ <Stop offset="0%" stopColor={bgColor} stopOpacity="0.65" />
49
+ <Stop offset="70%" stopColor={bgColor} stopOpacity="1" />
50
+ </SvgGradient>
51
+
52
+ <SvgGradient id={`highlightGrad_${id}`} x1="0%" y1="0%" x2="100%" y2="100%">
53
+ <Stop offset="0%" stopColor={borderColor} stopOpacity="0.65" />
54
+ <Stop offset="60%" stopColor={bgColor} stopOpacity="1" />
55
+ </SvgGradient>
56
+ </Defs>
57
+
58
+ {/* Background */}
59
+ <Path d={bg} fill={`url(#btnGrad_${id})`} />
60
+
61
+ {/* Shadow: top + right */}
62
+ <Path
63
+ d={`
64
+ M ${skewX},-1.6
65
+ L ${W - skewX},${skewYT + 3}
66
+ L ${W - skewX},${crT + 5}
67
+ L ${W},${crB - 16}
68
+ `}
69
+ fill="none"
70
+ stroke={`url(#highlightGrad_${id})`}
71
+ strokeWidth={6}
72
+ strokeLinejoin="miter"
73
+ transform="translate(2 3)"
74
+ />
75
+
76
+ {/* Shadow: bottom */}
77
+ <Path
78
+ d={`
79
+ M ${W - skewX},${crB}
80
+ L ${skewX + 10},${H - 0.5}
81
+ `}
82
+ fill="none"
83
+ stroke={`url(#highlightGrad_${id})`}
84
+ strokeWidth={6}
85
+ strokeLinejoin="miter"
86
+ transform="translate(2 3)"
87
+ />
88
+
89
+ {/* Shadow: left top */}
90
+ <Path
91
+ d={`
92
+ M ${skewX + 0.2},-1.4
93
+ L -0.5,${crT - 2}
94
+ `}
95
+ fill="none"
96
+ stroke={borderColor + "99"}
97
+ strokeWidth={2.7}
98
+ strokeLinejoin="miter"
99
+ transform="translate(2 3)"
100
+ />
101
+
102
+ {/* Shadow: left body */}
103
+ <Path
104
+ d={`
105
+ M -1,${crT - 3}
106
+ L -1,${H - 20}
107
+ L -0.5,${H - 20}
108
+ L 1,${crT - 2}
109
+ Z
110
+ `}
111
+ fill={borderColor + "99"}
112
+ transform="translate(2 3)"
113
+ />
114
+
115
+ {/* Border: top + right */}
116
+ <Path
117
+ d={`
118
+ M ${skewX},-0.5
119
+ L ${W - skewX},${skewYT}
120
+ L ${W},${crT}
121
+ L ${W},${crB}
122
+ `}
123
+ fill="none"
124
+ stroke={borderColor}
125
+ strokeWidth={6}
126
+ strokeLinejoin="miter"
127
+ />
128
+
129
+ {/* Border: bottom */}
130
+ <Path
131
+ d={`
132
+ M ${W + 1},${crB - 2.2}
133
+ L ${W - skewX},${skewYB}
134
+ L ${skewX + 10},${H + 2}
135
+ `}
136
+ fill="none"
137
+ stroke={borderColor}
138
+ strokeWidth={6}
139
+ strokeLinejoin="miter"
140
+ />
141
+
142
+ {/* Border: left top diagonal */}
143
+ <Path
144
+ d={`
145
+ M ${skewX},0
146
+ L 0,${crT}
147
+ `}
148
+ fill="none"
149
+ stroke={borderColor}
150
+ strokeWidth={2.5}
151
+ strokeLinejoin="miter"
152
+ />
153
+
154
+ {/* Border: left body fill */}
155
+ <Path
156
+ d={`
157
+ M -1,${crT - 1}
158
+ L 0,${H - 14}
159
+ L 1,${H - 14}
160
+ L 1,${crT + 1}
161
+ Z
162
+ `}
163
+ fill={borderColor}
164
+ />
165
+ </Svg>
166
+ );
167
+ }
168
+
169
+ export default function SharpOtpInput({ length = 6, onComplete, onChange, bgColor }: OtpInputProps) {
170
+ const { colors } = useAppTheme();
171
+ const [digits, setDigits] = useState<string[]>(Array(length).fill(""));
172
+ const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
173
+ const refs = useRef<TextInput[]>([]);
174
+
175
+ const handleChange = (text: string, index: number) => {
176
+ const newDigits = [...digits];
177
+ newDigits[index] = text;
178
+ setDigits(newDigits);
179
+
180
+ const code = newDigits.join("");
181
+ onChange?.(code);
182
+
183
+ if (text && index < length - 1) {
184
+ refs.current[index + 1]?.focus();
185
+ } else if (!text && index > 0) {
186
+ refs.current[index - 1]?.focus();
187
+ }
188
+
189
+ if (code.length === length && newDigits.every((d) => d !== "")) {
190
+ onComplete?.(code);
191
+ }
192
+ };
193
+
194
+ return (
195
+ <View style={{ flexDirection: "row", justifyContent: "center", gap: 10 }}>
196
+ {digits.map((digit, index) => (
197
+ <View key={index} style={{ width: BOX, height: BOX }}>
198
+ <SharpBox
199
+ bgColor={bgColor ?? colors.background.main}
200
+ borderColor={focusedIndex === index ? colors.primary : colors.semantic.border}
201
+ id={`otp_${index}`}
202
+ />
203
+ <TextInput
204
+ ref={(r) => { if (r) refs.current[index] = r; }}
205
+ style={{
206
+ position: "absolute",
207
+ width: BOX,
208
+ height: BOX,
209
+ fontSize: textSize.md,
210
+ color: colors.text.primary,
211
+ fontFamily: Fonts.montserrat.regular,
212
+ textAlign: "center",
213
+ backgroundColor: "transparent",
214
+ }}
215
+ placeholder="•"
216
+ placeholderTextColor={colors.text.placeholder}
217
+ keyboardType="number-pad"
218
+ maxLength={1}
219
+ value={digit}
220
+ onChangeText={(text) => handleChange(text, index)}
221
+ onFocus={() => setFocusedIndex(index)}
222
+ onBlur={() => setFocusedIndex(null)}
223
+ />
224
+ </View>
225
+ ))}
226
+ </View>
227
+ );
228
+ }
@@ -0,0 +1,155 @@
1
+ import { useState, useRef } from "react";
2
+ import { Text, View, TouchableOpacity, ScrollView, Modal } from "react-native";
3
+ import { useAppTheme } from "../../context/ThemeContext";
4
+ import { Fonts, textSize } from "../../tokens/typography";
5
+ import { Ionicons } from "@expo/vector-icons";
6
+ import SharpContainer from "./sharpContainer";
7
+ import ErrorMessage from "./error";
8
+
9
+ interface Option {
10
+ value: string;
11
+ label: string;
12
+ }
13
+
14
+ interface SharpSelectProps {
15
+ options: Option[];
16
+ value: string;
17
+ onChange: (value: string) => void;
18
+ label?: string;
19
+ placeholder?: string;
20
+ error?: string;
21
+ onClearError?: () => void;
22
+ height?: number;
23
+ bgColor?: string;
24
+ }
25
+
26
+ export default function SharpSelect({
27
+ options,
28
+ value,
29
+ onChange,
30
+ label,
31
+ placeholder = "Select an option",
32
+ error,
33
+ onClearError,
34
+ height = 50,
35
+ bgColor,
36
+ }: SharpSelectProps) {
37
+ const [open, setOpen] = useState(false);
38
+ const [dropdownPos, setDropdownPos] = useState({ x: 0, y: 0, width: 0 });
39
+ const triggerRef = useRef<View>(null);
40
+ const { colors } = useAppTheme();
41
+
42
+ const selectedLabel = options.find((o) => o.value === value)?.label;
43
+
44
+ const getBorderColor = () => {
45
+ if (error) return colors.semantic.error;
46
+ if (open) return colors.primary;
47
+ return colors.semantic.border;
48
+ };
49
+
50
+ const handleOpen = () => {
51
+ triggerRef.current?.measureInWindow((x, y, width, height) => {
52
+ setDropdownPos({ x, y: y + height, width });
53
+ setOpen(true);
54
+ });
55
+ onClearError?.();
56
+ };
57
+
58
+ return (
59
+ <View style={{ marginBottom: 4 }}>
60
+ {label && (
61
+ <Text style={{
62
+ color: colors.text.primary,
63
+ fontSize: textSize.xs,
64
+ fontFamily: Fonts.montserrat.semiBold,
65
+ marginBottom: 6,
66
+ }}>
67
+ {label}
68
+ </Text>
69
+ )}
70
+
71
+ <TouchableOpacity onPress={() => open ? setOpen(false) : handleOpen()} activeOpacity={0.8}>
72
+ <View ref={triggerRef}>
73
+ <SharpContainer
74
+ height={height}
75
+ bgColor={bgColor ?? colors.background.main}
76
+ borderColor={getBorderColor()}
77
+ >
78
+ <View style={{ flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16 }}>
79
+ <Text style={{
80
+ fontSize: textSize.sm,
81
+ fontFamily: Fonts.montserrat.regular,
82
+ color: selectedLabel ? colors.text.primary : colors.text.secondary,
83
+ }}>
84
+ {selectedLabel ?? placeholder}
85
+ </Text>
86
+ <Ionicons name={open ? "chevron-up" : "chevron-down"} size={16} color={colors.text.secondary} />
87
+ </View>
88
+ </SharpContainer>
89
+ </View>
90
+ </TouchableOpacity>
91
+
92
+ {error && <ErrorMessage message={error} />}
93
+
94
+ {/* Modal dropdown positioned below trigger */}
95
+ <Modal visible={open} transparent animationType="none">
96
+ {/* backdrop to close */}
97
+ <TouchableOpacity
98
+ style={{ flex: 1 }}
99
+ activeOpacity={1}
100
+ onPress={() => setOpen(false)}
101
+ >
102
+ <View style={{
103
+ position: "absolute",
104
+ top: dropdownPos.y + 4,
105
+ left: dropdownPos.x,
106
+ width: dropdownPos.width,
107
+ backgroundColor: bgColor ?? colors.background.card,
108
+ borderRadius: 10,
109
+ maxHeight: 220,
110
+ elevation: 10,
111
+ shadowColor: "#000",
112
+ shadowOffset: { width: 0, height: 4 },
113
+ shadowOpacity: 0.15,
114
+ shadowRadius: 6,
115
+ overflow: "hidden",
116
+ }}>
117
+ <ScrollView
118
+ bounces={false}
119
+ keyboardShouldPersistTaps="handled"
120
+ nestedScrollEnabled
121
+ >
122
+ {options.map((opt, index) => (
123
+ <TouchableOpacity
124
+ key={opt.value}
125
+ onPress={() => {
126
+ onChange(opt.value);
127
+ setOpen(false);
128
+ }}
129
+ style={{
130
+ paddingVertical: 14,
131
+ paddingHorizontal: 16,
132
+ flexDirection: "row",
133
+ alignItems: "center",
134
+ justifyContent: "space-between",
135
+ borderBottomWidth: index < options.length - 1 ? 1 : 0,
136
+ borderBottomColor: colors.background.main,
137
+ backgroundColor: opt.value === value ? colors.primary + "15" : "transparent",
138
+ }}
139
+ >
140
+ <Text style={{
141
+ fontSize: textSize.sm,
142
+ fontFamily: opt.value === value ? Fonts.montserrat.semiBold : Fonts.montserrat.regular,
143
+ color: opt.value === value ? colors.primary : colors.text.primary,
144
+ }}>
145
+ {opt.label}
146
+ </Text>
147
+ </TouchableOpacity>
148
+ ))}
149
+ </ScrollView>
150
+ </View>
151
+ </TouchableOpacity>
152
+ </Modal>
153
+ </View>
154
+ );
155
+ }