@towsila/expo-ui-library 3.4.0 → 3.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@towsila/expo-ui-library",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
4
4
  "description": "",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,120 +1,134 @@
1
- // import { useRouter } from "expo-router";
2
1
  import React, { useState } from "react";
3
- import TemplatePage from "../reusable/templatePage";
4
2
  import { Text, TouchableOpacity, View } from "react-native";
5
- import TermsPopup from "../ui/termsPopup";
6
- import { Fonts, textSize } from "../../tokens/typography";
7
- import { useAppTheme } from "../../context/ThemeContext";
8
3
  import { Field } from "../../types/formFields";
4
+ import { useAppTheme } from "../../context/ThemeContext";
5
+ import { useLanguage } from "../../context/LanguageContext";
9
6
  import { useFormSubmit } from "../../hooks/useSignup";
10
- import ErrorMessage from "../ui/error";
7
+ import { ScrollWithIndicator } from "../ui/scrollIndicator";
8
+ import { DynamicForm } from "../ui/dynamicForm";
11
9
  import SharpButton from "../ui/sharpButton";
12
- import { SharpDynamicForm } from "../ui/sharpDynamicForm";
13
- interface SignupProps{
14
- title: string;
15
- subtitle: string;
16
- formState: Record<string, string>;
17
- setFormState: React.Dispatch<React.SetStateAction<Record<string, string>>>;
18
- onSubmit: (formState: Record<string, string>) => Promise<void>;
19
- onLogin: () => void;
20
- errorMessage?: string;
21
- haveAccountLabel: string;
22
- loginLabel: string;
23
- buttonLabel: string;
24
- fields: Field[];
25
- nextLabel: string;
26
- backLabel: string;
27
- generalTerms: string[];
28
- dialogTitle:string;
29
- dialogDescription: string;
30
- dialogButton: string;
31
- t?: (key: string) => string;
10
+ import TermsPopup from "../ui/termsPopup";
11
+ import { Fonts, textSize } from "../../tokens/typography";
12
+
13
+ interface SignupProps {
14
+ title: string;
15
+ subtitle: string;
16
+ formState: Record<string, string>;
17
+ setFormState: React.Dispatch<React.SetStateAction<Record<string, string>>>;
18
+ onSubmit: (formState: Record<string, string>) => Promise<void>;
19
+ onLogin: () => void;
20
+ errorMessage?: string;
21
+ haveAccountLabel: string;
22
+ loginLabel: string;
23
+ buttonLabel: string;
24
+ fields: Field[];
25
+ nextLabel: string;
26
+ backLabel: string;
27
+ generalTerms: string[];
28
+ dialogTitle: string;
29
+ dialogDescription: string;
30
+ dialogButton: string;
31
+ t?: (key: string) => string;
32
+ appLogo?: React.ReactNode;
32
33
  }
33
- export default function SignupPage({
34
- title,
35
- subtitle,
36
- formState,
37
- setFormState,
38
- onSubmit,
39
- onLogin,
40
- errorMessage,
41
- haveAccountLabel,
42
- loginLabel,
43
- buttonLabel,
44
- fields,
45
- nextLabel,
46
- backLabel,
34
+
35
+ export default function SignUpScreen({
36
+ formState,
37
+ setFormState,
38
+ onSubmit,
39
+ onLogin,
40
+ haveAccountLabel,
41
+ loginLabel,
42
+ buttonLabel,
43
+ fields,
44
+ nextLabel,
45
+ backLabel,
47
46
  generalTerms,
48
47
  dialogTitle,
49
48
  dialogDescription,
50
49
  dialogButton,
51
50
  t,
51
+ appLogo,
52
52
  }: SignupProps) {
53
- const [showTerms, setShowTerms] = useState(true);
54
- const {colors} = useAppTheme();
55
- const [isLastStep, setIsLastStep] = useState(false);
56
-
57
- const {handleSubmit, errors, clearError, loading, setErrors} = useFormSubmit({
53
+ const [showTerms, setShowTerms] = useState(true);
54
+ const { colors } = useAppTheme();
55
+ const {isRTL} = useLanguage();
56
+ const { handleSubmit, errors, clearError, loading, setErrors } = useFormSubmit({
58
57
  fields,
59
58
  formState,
60
59
  onSubmit,
61
- });
60
+ });
61
+
62
62
  return (
63
- <TemplatePage
64
- title={title}
65
- subtitle={subtitle}
66
- body = {
67
- <View style={{gap:10}}>
68
- {errorMessage && <ErrorMessage message={errorMessage} position="center" fontSize={textSize.md} />}
69
- <SharpDynamicForm
70
- onStepChange={(step, total) => setIsLastStep(step === total - 1)}
71
- fields={fields}
72
- formState={formState}
73
- setFormState={setFormState}
74
- errors={errors}
75
- clearError={clearError}
76
- nextLabel={nextLabel}
77
- backLabel={backLabel}
78
- setErrors={setErrors}
79
- translator={t}
80
- />
81
- {isLastStep && (
82
- <SharpButton
83
- text={buttonLabel}
84
- bgColor={colors.primary}
85
- onPress={handleSubmit}
86
- loading={loading}
87
- height={50}
88
- />
89
- )}
90
- <TermsPopup
91
- title={dialogTitle}
92
- description={dialogDescription}
93
- buttonText={dialogButton}
94
- showTerms={showTerms}
95
- generalTerms={generalTerms}
96
- onAgree={()=>setShowTerms(false)}/>
97
- </View>
98
- }
99
- footer={
100
- <View style={{flexDirection: "row",
101
- justifyContent: "center",
102
- alignItems: "center",
103
- gap: 4,
104
- marginTop: 15,}}>
105
- <Text style={{fontSize: textSize.sm,
106
- color: colors.text.secondary,
107
- fontFamily: Fonts.montserrat.regular,}}>{haveAccountLabel}</Text>
108
- <TouchableOpacity onPress={onLogin}>
109
- <Text
110
- style={{ fontSize: textSize.sm, fontFamily: Fonts.montserrat.semiBold, color: colors.primary }}
111
- >
112
- {loginLabel}
113
- </Text>
114
- </TouchableOpacity>
115
- </View>
116
- }
117
- marginTop={-150}
118
- />
63
+ <View style={{ flex: 1, paddingHorizontal: 25, paddingVertical: 40 }}>
64
+ {/* Fixed: logo */}
65
+ <View style={{ alignItems: "center" }}>{appLogo}</View>
66
+
67
+ {/* Scrollable: form only, with vertical progress indicator */}
68
+ <ScrollWithIndicator
69
+ keyboardShouldPersistTaps="handled"
70
+ >
71
+ <DynamicForm
72
+ fields={fields}
73
+ formState={formState}
74
+ setFormState={setFormState}
75
+ errors={errors}
76
+ clearError={clearError}
77
+ nextLabel={nextLabel}
78
+ backLabel={backLabel}
79
+ setErrors={setErrors}
80
+ translator={t}
81
+ />
82
+ </ScrollWithIndicator>
83
+
84
+ {/* Fixed: button + login link */}
85
+ <View style={{ gap: 2, paddingTop: 20 }}>
86
+ <SharpButton
87
+ text={buttonLabel}
88
+ bgColor={colors.primary}
89
+ onPress={handleSubmit}
90
+ loading={loading}
91
+ height={50}
92
+ />
93
+ <TermsPopup
94
+ title={dialogTitle}
95
+ description={dialogDescription}
96
+ buttonText={dialogButton}
97
+ showTerms={showTerms}
98
+ generalTerms={generalTerms}
99
+ onAgree={() => setShowTerms(false)}
100
+ />
101
+ <View
102
+ style={{
103
+ flexDirection:isRTL ? "row":"row-reverse",
104
+ justifyContent:isRTL ? "flex-end" :"flex-start",
105
+ alignItems: "center",
106
+ gap: 4,
107
+ marginTop: 5,
108
+ }}
109
+ >
110
+ <Text
111
+ style={{
112
+ fontSize: textSize.xs,
113
+ color: colors.text.secondary,
114
+ fontFamily: Fonts.montserrat.regular,
115
+ }}
116
+ >
117
+ {haveAccountLabel}
118
+ </Text>
119
+ <TouchableOpacity onPress={onLogin}>
120
+ <Text
121
+ style={{
122
+ fontSize: textSize.xs,
123
+ fontFamily: Fonts.montserrat.semiBold,
124
+ color: colors.primary,
125
+ }}
126
+ >
127
+ {loginLabel}
128
+ </Text>
129
+ </TouchableOpacity>
130
+ </View>
131
+ </View>
132
+ </View>
119
133
  );
120
134
  }
@@ -1,7 +1,8 @@
1
1
  import { View, Text, StyleSheet, Modal, Animated, Dimensions, ViewStyle } from "react-native";
2
- import { useAppTheme } from "../../context/ThemeContext";
3
- import { textSize, Fonts } from "../../tokens/typography";
4
2
  import { useEffect, useRef } from "react";
3
+ import { Fonts, textSize } from "../../tokens/typography";
4
+ import { useAppTheme } from "../../context/ThemeContext";
5
+ import { useLanguage } from "../../context/LanguageContext";
5
6
  import SharpButton from "./sharpButton";
6
7
 
7
8
  const { height } = Dimensions.get("window");
@@ -91,7 +92,7 @@ const handleClose = () => {
91
92
  };
92
93
 
93
94
 
94
-
95
+ const {isRTL} = useLanguage();
95
96
 
96
97
  return (
97
98
  <Modal
@@ -119,6 +120,7 @@ const handleClose = () => {
119
120
  fontSize: textSize.xxl,
120
121
  color: colors.text.primary,
121
122
  marginBottom: 10,
123
+ textAlign:isRTL ? "left" :"right"
122
124
  }}>
123
125
  {title}
124
126
  </Text>
@@ -1,11 +1,7 @@
1
1
  import { useState } from "react";
2
- import { I18nManager, ScrollView, Text, TouchableOpacity, View } from "react-native";
3
- import Input from "./input";
2
+ import {View } from "react-native";
4
3
  import { Field } from "../../types/formFields";
5
- import { useAppTheme } from "../../context/ThemeContext";
6
- import { validateForm } from "../../utils/inputValidator/registerValidator";
7
- import { Fonts, textSize } from "../../tokens/typography";
8
- import {Arrow, ArrowBack} from "@towsila/icons";
4
+ import Input from "./input";
9
5
  interface Props {
10
6
  fields: Field[];
11
7
  formState: Record<string, string>;
@@ -16,67 +12,24 @@ interface Props {
16
12
  onStepChange?: (step: number, total: number) => void;
17
13
  nextLabel?: string;
18
14
  backLabel?: string;
15
+ translator?: (key: string) => string;
19
16
  }
20
17
  // can i split it into 2 not only move the password if the number of fields is > 4
21
- function splitIntoSteps(fields: Field[]): Field[][] {
22
- const info = fields.filter((f) => f.type !== "password");
23
- const security = fields.filter((f) => f.type === "password");
24
- const steps: Field[][] = [];
25
-
26
- if (info.length > 4) {
27
- const mid = Math.ceil(info.length / 2);
28
- steps.push(info.slice(0, mid));
29
- steps.push(info.slice(mid));
30
- } else if (info.length > 0) {
31
- steps.push(info);
32
- }
33
-
34
- // Add security fields as separate step
35
- if (security.length > 0) {
36
- steps.push(security);
37
- }
38
-
39
- return steps;
40
- }
41
18
 
42
19
  export const DynamicForm: React.FC<Props> = ({
43
20
  fields,
44
21
  formState,
45
22
  setFormState,
46
23
  errors,
47
- setErrors,
48
24
  clearError,
49
- onStepChange,
50
- nextLabel,
51
- backLabel,
25
+ translator,
52
26
  }) => {
53
27
  const [showPasswordField, setShowPasswordField] = useState<Record<string, boolean>>({});
54
- const [currentStep, setCurrentStep] = useState(0);
55
- const { colors } = useAppTheme();
56
-
57
- const steps = splitIntoSteps(fields);
58
- const totalSteps = steps.length;
59
- const stepFields = steps[currentStep] ?? [];
60
-
61
- const goNext = () => {
62
- // validate current step fields before proceeding
63
- const newErrors = validateForm(stepFields, formState);
64
- setErrors((prev) => ({ ...prev, ...newErrors }));
65
- if (Object.keys(newErrors).length > 0) return;
66
28
 
67
- const next = Math.min(currentStep + 1, totalSteps - 1);
68
- setCurrentStep(next);
69
- onStepChange?.(next, totalSteps);
70
- };
71
-
72
- const goPrev = () => {
73
- const prev = Math.max(currentStep - 1, 0);
74
- setCurrentStep(prev);
75
- onStepChange?.(prev, totalSteps);
76
- };
77
-
78
- const renderInput = (field: Field) => (
79
- <View key={field.name} style={{ marginBottom: 10 }}>
29
+ return (
30
+ <View style={{ gap: 4, paddingBottom: 20 }}>
31
+ {fields.map((field) =>
32
+ <View key={field.name} style={{ marginBottom: 10 }}>
80
33
  <Input
81
34
  value={formState[field.name] || ""}
82
35
  onChangeText={(text) => setFormState((prev) => ({ ...prev, [field.name]: text }))}
@@ -94,63 +47,12 @@ export const DynamicForm: React.FC<Props> = ({
94
47
  }
95
48
  secureTextEntry={field.secureTextEntry && !showPasswordField[field.name]}
96
49
  returnKeyType="next"
97
- error={errors[field.name]}
50
+ error={errors[field.name] ? translator?.(errors[field.name]) : ""}
98
51
  onClearError={() => clearError(field.name)}
99
52
  label={field.label}
100
53
  />
101
54
  </View>
102
- );
103
-
104
- return (
105
- <View style={{ gap: 4, paddingBottom: 20 }}>
106
-
107
- {/* Stepper — only show if more than 1 step */}
108
- {totalSteps > 1 && (
109
- <View style={{ flexDirection: "row", alignItems: "center", marginBottom: 12 }}>
110
- {/* Back arrow */}
111
- <View style={{ flex: 1 }}>
112
- {currentStep > 0 && backLabel && (
113
- <TouchableOpacity onPress={goPrev} style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-start", gap: 6 }}>
114
- <ArrowBack width={16} height={16} color={colors.text.secondary} style={[I18nManager.isRTL && { transform: [{ scaleX: -1 }] },]}/>
115
- <Text style={{ color: colors.text.secondary, fontFamily: Fonts.montserrat.semiBold, fontSize: textSize.sm }}>
116
- {backLabel}
117
- </Text>
118
- </TouchableOpacity>
119
- )}
120
- </View>
121
-
122
- {/* Pills */}
123
- <View style={{ flexDirection: "row", gap: 8 }}>
124
- {steps.map((_, i) => (
125
- <View key={i} style={{
126
- width: i === currentStep ? 28 : 10,
127
- height: 6,
128
- borderRadius: 3,
129
- backgroundColor: i <= currentStep ? colors.primary : colors.text.placeholder + "40",
130
- }} />
131
- ))}
132
- </View>
133
- <View style={{ flex: 1 }} />
134
- </View>
135
- )}
136
-
137
- {/* Fields */}
138
- {stepFields.map((f) => renderInput(f))}
139
-
140
- {/* Navigation */}
141
- {totalSteps > 1 && (
142
- <View style={{ gap: 10, marginTop: 4 }}>
143
- {currentStep < totalSteps - 1 && (
144
- <TouchableOpacity onPress={goNext} style={{ flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 6 }}>
145
- <Text style={{ color: colors.text.secondary, fontFamily: Fonts.montserrat.semiBold, fontSize: textSize.sm }}>
146
- {nextLabel}
147
- </Text>
148
- <Arrow width={16} height={16} color={colors.text.secondary} style={[I18nManager.isRTL && { transform: [{ scaleX: -1 }] },]}/>
149
- </TouchableOpacity>
150
- )}
151
-
152
- </View>
153
- )}
55
+ )}
154
56
  </View>
155
57
  );
156
58
  };
@@ -0,0 +1,130 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import {
3
+ Animated,
4
+ LayoutChangeEvent,
5
+ ScrollViewProps,
6
+ View,
7
+ } from "react-native";
8
+ import { useAppTheme } from "../../context/ThemeContext";
9
+ import { useLanguage } from "../../context/LanguageContext";
10
+
11
+ interface Props extends ScrollViewProps {
12
+ trackWidth?: number;
13
+ trackLength?: number;
14
+ children: React.ReactNode;
15
+ }
16
+
17
+ const IDLE_OPACITY = 0.45;
18
+
19
+ export const ScrollWithIndicator: React.FC<Props> = ({
20
+ children,
21
+ trackWidth = 5,
22
+ trackLength = 200,
23
+ style,
24
+ contentContainerStyle,
25
+ ...rest
26
+ }) => {
27
+ const { colors } = useAppTheme();
28
+ const { isRTL } = useLanguage();
29
+
30
+ const scrollY = useRef(new Animated.Value(0)).current;
31
+ const fade = useRef(new Animated.Value(IDLE_OPACITY)).current;
32
+ const awake = useRef(false);
33
+ const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+
35
+ const [viewH, setViewH] = useState(0);
36
+ const [contentH, setContentH] = useState(0);
37
+
38
+ const scrollable = viewH > 0 && contentH > viewH + 1;
39
+
40
+ // short fixed-length track instead of full height
41
+ const trackH = Math.min(trackLength, viewH);
42
+ const thumbH = scrollable
43
+ ? Math.min(trackH * 0.7, Math.max(24, (viewH / contentH) * trackH))
44
+ : 0;
45
+ const maxScroll = Math.max(contentH - viewH, 1);
46
+ const maxThumb = Math.max(trackH - thumbH, 1);
47
+
48
+ const translateY = scrollY.interpolate({
49
+ inputRange: [0, maxScroll],
50
+ outputRange: [0, maxThumb],
51
+ extrapolate: "clamp",
52
+ });
53
+
54
+ // brighter while scrolling, softer when idle
55
+ const wake = () => {
56
+ if (hideTimer.current) clearTimeout(hideTimer.current);
57
+ if (!awake.current) {
58
+ awake.current = true;
59
+ Animated.timing(fade, { toValue: 1, duration: 120, useNativeDriver: true }).start();
60
+ }
61
+ hideTimer.current = setTimeout(() => {
62
+ awake.current = false;
63
+ Animated.timing(fade, { toValue: IDLE_OPACITY, duration: 400, useNativeDriver: true }).start();
64
+ }, 900);
65
+ };
66
+
67
+ useEffect(() => {
68
+ return () => {
69
+ if (hideTimer.current) clearTimeout(hideTimer.current);
70
+ };
71
+ }, []);
72
+
73
+ return (
74
+ <View style={[{ flex: 1, flexDirection: isRTL ? "row" : "row-reverse" }, style]}>
75
+ <Animated.ScrollView
76
+ {...rest}
77
+ style={{ flex: 1 }}
78
+ contentContainerStyle={contentContainerStyle}
79
+ showsVerticalScrollIndicator={false}
80
+ scrollEventThrottle={10}
81
+ onLayout={(e: LayoutChangeEvent) => setViewH(e.nativeEvent.layout.height)}
82
+ onContentSizeChange={(_, h) => setContentH(h)}
83
+ onScroll={Animated.event(
84
+ [{ nativeEvent: { contentOffset: { y: scrollY } } }],
85
+ { useNativeDriver: true, listener: wake }
86
+ )}
87
+ >
88
+ {children}
89
+ </Animated.ScrollView>
90
+
91
+ {scrollable && (
92
+ <Animated.View
93
+ pointerEvents="none"
94
+ style={[
95
+ {
96
+ width: trackWidth,
97
+ height: trackH,
98
+ alignSelf: "center",
99
+ opacity: fade,
100
+ },
101
+ isRTL ? { marginLeft: 15 } : { marginRight: 15 },
102
+ ]}
103
+ >
104
+ {/* soft track */}
105
+ <View
106
+ style={{
107
+ position: "absolute",
108
+ top: 0,
109
+ bottom: 0,
110
+ width: trackWidth,
111
+ borderRadius: trackWidth / 2,
112
+ backgroundColor: colors.primary,
113
+ opacity: 0.15,
114
+ }}
115
+ />
116
+ {/* thumb */}
117
+ <Animated.View
118
+ style={{
119
+ width: trackWidth,
120
+ height: thumbH,
121
+ borderRadius: trackWidth / 2,
122
+ backgroundColor: colors.primary,
123
+ transform: [{ translateY }],
124
+ }}
125
+ />
126
+ </Animated.View>
127
+ )}
128
+ </View>
129
+ );
130
+ };
@@ -1,7 +1,8 @@
1
1
  import { ScrollView, Text, View } from "react-native";
2
2
  import CustomPopUp from "./customPopup";
3
- import { Fonts, textSize } from "../../tokens/typography";
4
3
  import { useAppTheme } from "../../context/ThemeContext";
4
+ import { useLanguage } from "../../context/LanguageContext";
5
+ import { Fonts, textSize } from "../../tokens/typography";
5
6
 
6
7
  interface TermsPopupProps{
7
8
  showTerms: boolean;
@@ -13,6 +14,7 @@ interface TermsPopupProps{
13
14
  }
14
15
  export default function TermsPopup({showTerms, generalTerms, onAgree, title, description, buttonText}: TermsPopupProps){
15
16
  const {colors} = useAppTheme();
17
+ const {isRTL} = useLanguage();
16
18
  return (
17
19
  <CustomPopUp
18
20
  visible={showTerms}
@@ -23,6 +25,7 @@ export default function TermsPopup({showTerms, generalTerms, onAgree, title, des
23
25
  fontSize: textSize.sm,
24
26
  color: colors.text.secondary,
25
27
  marginBottom: 4,
28
+ textAlign:isRTL ? "left" :"right"
26
29
  }}>
27
30
  {description}
28
31
  </Text>
@@ -34,7 +37,7 @@ export default function TermsPopup({showTerms, generalTerms, onAgree, title, des
34
37
  showsVerticalScrollIndicator={false}
35
38
  >
36
39
  {generalTerms.map((term: string, i: number) => (
37
- <View key={i} style={{ flexDirection: "row", gap: 8, marginBottom: 8 }}>
40
+ <View key={i} style={{ flexDirection: isRTL ? "row" : "row-reverse", gap: 8, marginBottom: 8 }}>
38
41
  <Text style={{ color: colors.primary }}>•</Text>
39
42
  <Text style={{
40
43
  fontFamily: Fonts.montserrat.regular,
@@ -42,6 +45,7 @@ export default function TermsPopup({showTerms, generalTerms, onAgree, title, des
42
45
  color: colors.text.secondary,
43
46
  flex: 1,
44
47
  lineHeight: 20,
48
+ textAlign:isRTL ? "left" :"right"
45
49
  }}>
46
50
  {term}
47
51
  </Text>
package/src/index.ts CHANGED
@@ -67,4 +67,5 @@ export {useArrivalDetection} from "./hooks/useArrivalDetection";
67
67
  export {default as Toggle} from "./components/ui/toggle";
68
68
  export {default as BackIcon} from "./components/ui/backIcon";
69
69
  export {default as SuccessScreen} from "./components/screens/sucessScreen";
70
- export {default as AnimatedSuccessIcon} from "./components/ui/AnimatedSuccessIcon";
70
+ export {default as AnimatedSuccessIcon} from "./components/ui/AnimatedSuccessIcon";
71
+ export {ScrollWithIndicator} from "./components/ui/scrollIndicator"