nx-react-native-cli 2.7.1 → 3.0.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.
Files changed (56) hide show
  1. package/lib/index.cjs +27 -27
  2. package/package.json +1 -1
  3. package/templates/shared/apps/mobile/src/app/index.tsx +13 -8
  4. package/templates/shared/apps/mobile/src/components/atoms/AlertManager/alert-manager.component.tsx +134 -0
  5. package/templates/shared/apps/mobile/src/components/atoms/AlertManager/alert-manager.types.ts +18 -0
  6. package/templates/shared/apps/mobile/src/components/atoms/AlertManager/alert.service.ts +27 -0
  7. package/templates/shared/apps/mobile/src/components/atoms/AlertManager/index.ts +3 -0
  8. package/templates/shared/apps/mobile/src/components/atoms/BottomSheet/bottom-sheet.component.tsx +14 -8
  9. package/templates/shared/apps/mobile/src/components/atoms/Button/button.component.tsx +1 -1
  10. package/templates/shared/apps/mobile/src/components/atoms/DateModalInput/date-modal-input.component.tsx +69 -0
  11. package/templates/shared/apps/mobile/src/components/atoms/DateModalInput/index.ts +1 -0
  12. package/templates/shared/apps/mobile/src/components/atoms/DatePicker/date-picker.component.tsx +44 -0
  13. package/templates/shared/apps/mobile/src/components/atoms/DatePicker/index.ts +1 -0
  14. package/templates/shared/apps/mobile/src/components/atoms/DateTextInput/date-text-input.component.tsx +218 -0
  15. package/templates/shared/apps/mobile/src/components/atoms/DateTextInput/index.ts +1 -0
  16. package/templates/shared/apps/mobile/src/components/atoms/Divider/divider-component.tsx +1 -1
  17. package/templates/shared/apps/mobile/src/components/atoms/GradientBackground/gradient-background.component.tsx +45 -0
  18. package/templates/shared/apps/mobile/src/components/atoms/GradientBackground/index.ts +1 -0
  19. package/templates/shared/apps/mobile/src/components/atoms/InputLayout/input-layout.component.tsx +12 -4
  20. package/templates/shared/apps/mobile/src/components/atoms/KeyboardAccessory/keyboard-accessory.component.tsx +6 -3
  21. package/templates/shared/apps/mobile/src/components/atoms/Modal/modal.component.tsx +2 -0
  22. package/templates/shared/apps/mobile/src/components/atoms/ScreenLoader/screen-loader.component.tsx +6 -1
  23. package/templates/shared/apps/mobile/src/components/atoms/SelectDropdown/index.ts +1 -0
  24. package/templates/shared/apps/mobile/src/components/atoms/SelectDropdown/select-dropdown.component.tsx +223 -0
  25. package/templates/shared/apps/mobile/src/components/atoms/Skeleton/skeleton.component.tsx +1 -1
  26. package/templates/shared/apps/mobile/src/components/atoms/TextInput/bottom-sheet-text-input.component.tsx +4 -3
  27. package/templates/shared/apps/mobile/src/components/atoms/TextInput/text-input.component.tsx +8 -4
  28. package/templates/shared/apps/mobile/src/components/atoms/ThemeManager/index.ts +1 -0
  29. package/templates/shared/apps/mobile/src/components/atoms/ThemeManager/theme-manager.component.tsx +27 -0
  30. package/templates/shared/apps/mobile/src/components/atoms/ToastManager/index.ts +3 -0
  31. package/templates/shared/apps/mobile/src/components/atoms/ToastManager/toast-manager.component.tsx +109 -0
  32. package/templates/shared/apps/mobile/src/components/atoms/ToastManager/toast-manager.types.ts +10 -0
  33. package/templates/shared/apps/mobile/src/components/atoms/ToastManager/toast.service.ts +27 -0
  34. package/templates/shared/apps/mobile/src/components/atoms/Typography/typography.component.tsx +1 -1
  35. package/templates/shared/apps/mobile/src/components/atoms/index.ts +8 -0
  36. package/templates/shared/apps/mobile/src/components/molecules/BackButton/back-button.component.tsx +1 -1
  37. package/templates/shared/apps/mobile/src/components/molecules/ScreenContainer/screen-container.component.tsx +2 -22
  38. package/templates/shared/apps/mobile/src/components/molecules/ScreenHeader/screen-header.component.tsx +2 -2
  39. package/templates/shared/apps/mobile/src/hooks/index.ts +1 -0
  40. package/templates/shared/apps/mobile/src/hooks/usePushNotifications.hook.ts +104 -0
  41. package/templates/shared/apps/mobile/src/hooks/useToggleDarkMode.hook.tsx +24 -2
  42. package/templates/shared/apps/mobile/src/icons/alert-triangle.svg +5 -0
  43. package/templates/shared/apps/mobile/src/icons/check-circle.svg +4 -0
  44. package/templates/shared/apps/mobile/src/icons/chevron-down.svg +1 -0
  45. package/templates/shared/apps/mobile/src/icons/chevron-right.svg +1 -0
  46. package/templates/shared/apps/mobile/src/icons/index.ts +18 -1
  47. package/templates/shared/apps/mobile/src/icons/info.svg +5 -0
  48. package/templates/shared/apps/mobile/src/icons/x-circle.svg +5 -0
  49. package/templates/shared/apps/mobile/src/routes/index.tsx +19 -14
  50. package/templates/shared/apps/mobile/src/screens/LandingScreen/landing.screen.tsx +232 -8
  51. package/templates/shared/apps/mobile/src/stores/local-storage.store.ts +9 -5
  52. package/templates/shared/apps/mobile/src/stores/theme.slice.ts +15 -0
  53. package/templates/shared/apps/mobile/src/stores/user.slice.ts +5 -1
  54. package/templates/shared/apps/mobile/src/tailwind/index.ts +3 -3
  55. package/templates/shared/apps/mobile/tailwind.config.js +14 -0
  56. package/templates/shared/patches/react-native-animatable+1.4.0.patch +71 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nx-react-native-cli",
3
- "version": "2.7.1",
3
+ "version": "3.0.1",
4
4
  "description": "A react native starter (with NX) cli script",
5
5
  "type": "module",
6
6
  "files": [
@@ -5,29 +5,34 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
5
5
  import 'react-native-get-random-values';
6
6
  import { KeyboardProvider } from 'react-native-keyboard-controller';
7
7
  import { SafeAreaProvider } from 'react-native-safe-area-context';
8
- import { useDeviceContext } from 'twrnc';
9
8
 
10
9
  import { persistOptions, queryClient } from './query-client';
11
10
 
12
- import { StorageManager } from '@/components';
11
+ import { AlertManager, StorageManager, ThemeManager, ToastManager } from '@/components';
13
12
  import ApplicationRoutes from '@/routes';
14
13
  import { tw } from '@/tailwind';
15
14
  import 'react-native-url-polyfill/auto';
16
15
 
17
- LogBox.ignoreLogs(['VirtualizedLists', 'onAnimatedValueUpdate']);
16
+ LogBox.ignoreLogs([
17
+ 'VirtualizedLists',
18
+ 'onAnimatedValueUpdate',
19
+ 'InteractionManager',
20
+ 'This method is deprecated (as well as all React Native Firebase namespaced API)',
21
+ ]);
18
22
 
19
23
  function Application() {
20
- useDeviceContext(tw, {
21
- initialColorScheme: 'light',
22
- });
23
-
24
24
  return (
25
25
  <GestureHandlerRootView style={tw`flex-1`}>
26
26
  <SafeAreaProvider>
27
27
  <KeyboardProvider navigationBarTranslucent statusBarTranslucent>
28
28
  <PersistQueryClientProvider client={queryClient} persistOptions={persistOptions}>
29
29
  <StorageManager>
30
- <ApplicationRoutes />
30
+ <>
31
+ <ThemeManager />
32
+ <ApplicationRoutes />
33
+ <AlertManager />
34
+ <ToastManager />
35
+ </>
31
36
  </StorageManager>
32
37
  </PersistQueryClientProvider>
33
38
  </KeyboardProvider>
@@ -0,0 +1,134 @@
1
+ import React, { useCallback, useImperativeHandle, useRef, useState } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import { SvgProps } from 'react-native-svg';
4
+
5
+ import { AlertManagerRef, AlertOptions, AlertVariant } from './alert-manager.types';
6
+ import { Alert } from './alert.service';
7
+
8
+ import { Modal } from '@/components/atoms';
9
+ import { Typography } from '@/components/atoms/Typography';
10
+ import { AlertTriangleIcon, CheckCircleIcon, InfoIcon, XCircleIcon } from '@/icons';
11
+ import { colors, tw } from '@/tailwind';
12
+
13
+ type QueuedAlert = AlertOptions & {
14
+ variant: AlertVariant;
15
+ };
16
+
17
+ type VariantConfig = {
18
+ color: string;
19
+ icon: React.FC<SvgProps>;
20
+ };
21
+
22
+ const ICON_SIZE = 24;
23
+
24
+ const VARIANT_CONFIG: Record<AlertVariant, VariantConfig> = {
25
+ error: { color: colors.error, icon: XCircleIcon },
26
+ info: { color: colors.secondary[500], icon: InfoIcon },
27
+ success: { color: colors.success, icon: CheckCircleIcon },
28
+ warning: { color: colors.primary[500], icon: AlertTriangleIcon },
29
+ };
30
+
31
+ export function AlertManager() {
32
+ const [queue, setQueue] = useState<QueuedAlert[]>([]);
33
+ const ref = useRef<AlertManagerRef>(null);
34
+
35
+ const currentAlert = queue[0];
36
+ const isVisible = !!currentAlert;
37
+
38
+ const handleDismiss = useCallback(() => {
39
+ setQueue((prev) => prev.slice(1));
40
+ }, []);
41
+
42
+ const handleActionPress = useCallback(
43
+ (onPress?: () => void) => {
44
+ handleDismiss();
45
+ onPress?.();
46
+ },
47
+ [handleDismiss],
48
+ );
49
+
50
+ useImperativeHandle(ref, () => ({
51
+ show: (variant: AlertVariant, options: AlertOptions) => {
52
+ setQueue((prev) => [...prev, { ...options, variant }]);
53
+ },
54
+ }));
55
+
56
+ React.useEffect(() => {
57
+ Alert.setRef({
58
+ show: (variant: AlertVariant, options: AlertOptions) => {
59
+ setQueue((prev) => [...prev, { ...options, variant }]);
60
+ },
61
+ });
62
+ }, []);
63
+
64
+ if (!currentAlert) {
65
+ return null;
66
+ }
67
+
68
+ const config = VARIANT_CONFIG[currentAlert.variant];
69
+ const IconComponent = config.icon;
70
+ const showIcon = !currentAlert.hideIcon;
71
+ const actions = currentAlert.actions?.length
72
+ ? currentAlert.actions
73
+ : [{ label: 'Ok', variant: 'default' as const }];
74
+
75
+ return (
76
+ <Modal isVisible={isVisible} onBackButtonPress={handleDismiss} onBackdropPress={handleDismiss}>
77
+ <View pointerEvents="box-none" style={tw`flex-1 items-center justify-center`}>
78
+ <View
79
+ style={tw`dark:border-divider dark:bg-sheet mx-8 w-[85%] rounded-2xl border border-gray-200 bg-white p-6`}
80
+ >
81
+ {/* Icon */}
82
+ {showIcon && (
83
+ <View
84
+ style={tw`dark:bg-surface mb-4 h-10 w-10 items-center justify-center rounded-full bg-gray-100`}
85
+ >
86
+ <IconComponent color={config.color} height={ICON_SIZE} width={ICON_SIZE} />
87
+ </View>
88
+ )}
89
+
90
+ {/* Title */}
91
+ <Typography style={tw`dark:text-foreground text-lg font-semibold text-gray-900`}>
92
+ {currentAlert.title}
93
+ </Typography>
94
+
95
+ {/* Message */}
96
+ {currentAlert.message ? (
97
+ <Typography style={tw`dark:text-subtitle mt-4 text-sm leading-relaxed text-gray-500`}>
98
+ {currentAlert.message}
99
+ </Typography>
100
+ ) : null}
101
+
102
+ {/* Action buttons */}
103
+ <View style={tw`mt-6 gap-3`}>
104
+ {actions.map((action) =>
105
+ action.variant === 'cancel' ? (
106
+ <Pressable
107
+ key={action.label}
108
+ style={tw`dark:border-divider dark:bg-surface items-center rounded-xl border border-gray-200 bg-gray-50 py-3.5`}
109
+ onPress={() => handleActionPress(action.onPress)}
110
+ >
111
+ <Typography
112
+ style={tw`dark:text-foreground text-base font-semibold text-gray-700`}
113
+ >
114
+ {action.label}
115
+ </Typography>
116
+ </Pressable>
117
+ ) : (
118
+ <Pressable
119
+ key={action.label}
120
+ style={tw`bg-primary-500 items-center rounded-xl py-3.5`}
121
+ onPress={() => handleActionPress(action.onPress)}
122
+ >
123
+ <Typography style={tw`text-base font-semibold text-white`}>
124
+ {action.label}
125
+ </Typography>
126
+ </Pressable>
127
+ ),
128
+ )}
129
+ </View>
130
+ </View>
131
+ </View>
132
+ </Modal>
133
+ );
134
+ }
@@ -0,0 +1,18 @@
1
+ export type AlertVariant = 'error' | 'info' | 'success' | 'warning';
2
+
3
+ export type AlertAction = {
4
+ label: string;
5
+ onPress?: () => void;
6
+ variant?: 'cancel' | 'default';
7
+ };
8
+
9
+ export type AlertOptions = {
10
+ actions?: AlertAction[];
11
+ hideIcon?: boolean;
12
+ message?: string;
13
+ title: string;
14
+ };
15
+
16
+ export type AlertManagerRef = {
17
+ show: (variant: AlertVariant, options: AlertOptions) => void;
18
+ };
@@ -0,0 +1,27 @@
1
+ import { AlertManagerRef, AlertOptions } from './alert-manager.types';
2
+
3
+ class AlertService {
4
+ private static ref: AlertManagerRef | null = null;
5
+
6
+ static error(options: AlertOptions) {
7
+ AlertService.ref?.show('error', options);
8
+ }
9
+
10
+ static info(options: AlertOptions) {
11
+ AlertService.ref?.show('info', options);
12
+ }
13
+
14
+ static setRef(ref: AlertManagerRef) {
15
+ AlertService.ref = ref;
16
+ }
17
+
18
+ static success(options: AlertOptions) {
19
+ AlertService.ref?.show('success', options);
20
+ }
21
+
22
+ static warning(options: AlertOptions) {
23
+ AlertService.ref?.show('warning', options);
24
+ }
25
+ }
26
+
27
+ export { AlertService as Alert };
@@ -0,0 +1,3 @@
1
+ export * from './alert-manager.component';
2
+ export * from './alert-manager.types';
3
+ export * from './alert.service';
@@ -24,6 +24,7 @@ export type BottomSheetProps = DefaultComponentProps & {
24
24
  enableDynamicSizing?: boolean;
25
25
  enablePanDownToClose?: boolean;
26
26
  handleComponent?: FC<BottomSheetHandleProps> | null;
27
+ hasScrollView?: boolean;
27
28
  onExpand?: () => void;
28
29
  sheetRef: RefObject<BottomSheetModal>;
29
30
  snapPoints?: string[];
@@ -83,6 +84,7 @@ export function BottomSheet(props: BottomSheetProps) {
83
84
  enableDynamicSizing = false,
84
85
  enablePanDownToClose = true,
85
86
  handleComponent = BottomSheetHandle,
87
+ hasScrollView = true,
86
88
  sheetRef,
87
89
  snapPoints = DEFAULT_SNAP_POINTS,
88
90
  style,
@@ -104,11 +106,11 @@ export function BottomSheet(props: BottomSheetProps) {
104
106
  ref={sheetRef}
105
107
  android_keyboardInputMode="adjustResize"
106
108
  backdropComponent={renderBackdrop}
107
- backgroundStyle={[tw`bg-gray-50`, backgroundStyle]}
109
+ backgroundStyle={[tw`dark:bg-sheet bg-white`, backgroundStyle]}
108
110
  enableDynamicSizing={enableDynamicSizing}
109
111
  enablePanDownToClose={enablePanDownToClose}
110
112
  handleComponent={handleComponent}
111
- handleIndicatorStyle={tw`bg-gray-50`}
113
+ handleIndicatorStyle={tw`dark:bg-divider bg-gray-300`}
112
114
  handleStyle={tw`rounded-tl-xl rounded-tr-xl`}
113
115
  keyboardBehavior="interactive"
114
116
  snapPoints={points}
@@ -120,12 +122,16 @@ export function BottomSheet(props: BottomSheetProps) {
120
122
  style,
121
123
  ]}
122
124
  >
123
- <BottomSheetScrollView
124
- contentContainerStyle={contentContainerStyle}
125
- keyboardShouldPersistTaps="handled"
126
- >
127
- {children}
128
- </BottomSheetScrollView>
125
+ {hasScrollView ? (
126
+ <BottomSheetScrollView
127
+ contentContainerStyle={contentContainerStyle}
128
+ keyboardShouldPersistTaps="handled"
129
+ >
130
+ {children}
131
+ </BottomSheetScrollView>
132
+ ) : (
133
+ children
134
+ )}
129
135
  </BottomSheetModal>
130
136
  );
131
137
  }
@@ -64,7 +64,7 @@ export function OutlinedButton(props: Props) {
64
64
  return (
65
65
  <Button
66
66
  {...rest}
67
- buttonStyle={tw`border-primary-500 border-2 bg-white`}
67
+ buttonStyle={tw`border-primary-500 border-2 bg-transparent`}
68
68
  textStyle={tw`text-primary-500`}
69
69
  />
70
70
  );
@@ -0,0 +1,69 @@
1
+ import dayjs from 'dayjs';
2
+ import React, { ReactNode, useState } from 'react';
3
+ import { StyleProp, TouchableOpacity, ViewStyle } from 'react-native';
4
+
5
+ import { DatePicker } from '@/components/atoms/DatePicker';
6
+ import { Typography } from '@/components/atoms/Typography';
7
+ import { defaultInputContainerStyle, defaultInputTextStyle, tw } from '@/tailwind';
8
+
9
+ const DEFAULT_FORMAT = 'MMMM D, YYYY';
10
+
11
+ type Props = {
12
+ error?: string;
13
+ format?: string;
14
+ maximumDate?: Date;
15
+ minimumDate?: Date;
16
+ onChange: (date: Date) => void;
17
+ placeholder?: string;
18
+ renderRight?: (value: Date) => ReactNode;
19
+ value: Date | undefined;
20
+ };
21
+
22
+ export function DateModalInput(props: Props) {
23
+ const {
24
+ error,
25
+ format = DEFAULT_FORMAT,
26
+ maximumDate,
27
+ minimumDate,
28
+ onChange,
29
+ placeholder = 'Select a date',
30
+ renderRight,
31
+ value,
32
+ } = props;
33
+ const [showDatePicker, setShowDatePicker] = useState(false);
34
+
35
+ const containerStyle: StyleProp<ViewStyle> = [
36
+ defaultInputContainerStyle,
37
+ tw`dark:border-divider dark:bg-surface items-center`,
38
+ error && tw`border-red-500`,
39
+ ];
40
+
41
+ return (
42
+ <>
43
+ <TouchableOpacity
44
+ activeOpacity={0.7}
45
+ style={containerStyle}
46
+ onPress={() => setShowDatePicker(true)}
47
+ >
48
+ <Typography
49
+ style={[defaultInputTextStyle, tw`dark:text-foreground`, !value && tw`text-placeholder`]}
50
+ >
51
+ {value ? dayjs(value).format(format) : placeholder}
52
+ </Typography>
53
+ {value && renderRight?.(value)}
54
+ </TouchableOpacity>
55
+ <DatePicker
56
+ date={value || new Date()}
57
+ isVisible={showDatePicker}
58
+ maximumDate={maximumDate}
59
+ minimumDate={minimumDate}
60
+ mode="date"
61
+ onCancel={() => setShowDatePicker(false)}
62
+ onConfirm={(selectedDate) => {
63
+ setShowDatePicker(false);
64
+ onChange(selectedDate);
65
+ }}
66
+ />
67
+ </>
68
+ );
69
+ }
@@ -0,0 +1 @@
1
+ export * from './date-modal-input.component';
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { TouchableHighlight } from 'react-native';
3
+ import DateTimePickerModal, {
4
+ ReactNativeModalDateTimePickerProps,
5
+ } from 'react-native-modal-datetime-picker';
6
+
7
+ import { Typography } from '@/components/atoms/Typography';
8
+ import { useLocalStorageStore } from '@/stores';
9
+ import { colors, tw } from '@/tailwind';
10
+
11
+ type Props = Omit<ReactNativeModalDateTimePickerProps, 'onCancel' | 'onConfirm'> & {
12
+ onCancel: () => void;
13
+ onConfirm: (date: Date) => void;
14
+ };
15
+
16
+ export function DatePicker(props: Props) {
17
+ const { onCancel, onConfirm, ...rest } = props;
18
+ const colorScheme = useLocalStorageStore((s) => s.colorScheme);
19
+ const isDark = colorScheme === 'dark';
20
+
21
+ return (
22
+ <DateTimePickerModal
23
+ backdropStyleIOS={tw`bg-black/40`}
24
+ buttonTextColorIOS={colors.primary[500]}
25
+ customCancelButtonIOS={({ onPress }) => (
26
+ <TouchableHighlight
27
+ style={tw`dark:bg-surface mt-2 items-center rounded-2xl bg-white py-4`}
28
+ underlayColor={isDark ? colors.background : colors.gray[100]}
29
+ onPress={onPress}
30
+ >
31
+ <Typography style={tw`dark:text-subtitle text-base font-semibold text-gray-500`}>
32
+ Cancel
33
+ </Typography>
34
+ </TouchableHighlight>
35
+ )}
36
+ isDarkModeEnabled={isDark}
37
+ modalStyleIOS={tw`rounded-2xl pb-4`}
38
+ pickerContainerStyleIOS={tw`dark:bg-sheet items-center justify-center bg-white`}
39
+ onCancel={onCancel}
40
+ onConfirm={onConfirm}
41
+ {...rest}
42
+ />
43
+ );
44
+ }
@@ -0,0 +1 @@
1
+ export * from './date-picker.component';
@@ -0,0 +1,218 @@
1
+ import dayjs from 'dayjs';
2
+ import customParseFormat from 'dayjs/plugin/customParseFormat';
3
+ import React, { ReactNode, Ref, useCallback, useEffect, useRef, useState } from 'react';
4
+ import { Pressable, StyleProp, TextInput, ViewStyle } from 'react-native';
5
+
6
+ import { Typography } from '@/components/atoms/Typography';
7
+ import CONFIG from '@/config';
8
+ import {
9
+ defaultInputContainerStyle,
10
+ defaultInputTextStyle,
11
+ focusedInputStyle,
12
+ tw,
13
+ } from '@/tailwind';
14
+
15
+ dayjs.extend(customParseFormat);
16
+
17
+ const DATE_FORMAT = 'MM/DD/YYYY';
18
+ const GUIDE_TEMPLATE = 'MM/DD/YYYY';
19
+ const MAX_DIGITS = 8;
20
+ const MONTH_SEPARATOR_INDEX = 2;
21
+ const DAY_SEPARATOR_INDEX = 4;
22
+
23
+ const fontFamily = CONFIG.IS_IOS ? { fontFamily: 'Menlo' } : { fontFamily: 'monospace' };
24
+
25
+ type Props = {
26
+ error?: string;
27
+ onChange: (date: Date | undefined) => void;
28
+ onValidationError?: (error: string | undefined) => void;
29
+ ref?: Ref<TextInput>;
30
+ renderRight?: (date: Date) => ReactNode;
31
+ value: Date | undefined;
32
+ };
33
+
34
+ const MONTH_FIRST_DIGIT_INDEX = 0;
35
+ const MONTH_SECOND_DIGIT_INDEX = 1;
36
+ const DAY_FIRST_DIGIT_INDEX = 2;
37
+ const DAY_SECOND_DIGIT_INDEX = 3;
38
+ const YEAR_FIRST_DIGIT_INDEX = 4;
39
+ const MAX_MONTH_FIRST_DIGIT = 1;
40
+ const MAX_DAY_FIRST_DIGIT = 3;
41
+
42
+ function isDigitValid(digit: string, index: number, previous: string): boolean {
43
+ const d = Number(digit);
44
+
45
+ if (index === MONTH_FIRST_DIGIT_INDEX) {
46
+ return d <= MAX_MONTH_FIRST_DIGIT;
47
+ }
48
+
49
+ if (index === MONTH_SECOND_DIGIT_INDEX) {
50
+ return previous[0] === '0' ? d >= 1 : d <= MONTH_SEPARATOR_INDEX;
51
+ }
52
+
53
+ if (index === DAY_FIRST_DIGIT_INDEX) {
54
+ return d <= MAX_DAY_FIRST_DIGIT;
55
+ }
56
+
57
+ if (index === DAY_SECOND_DIGIT_INDEX) {
58
+ return previous[MONTH_SEPARATOR_INDEX] === '3' ? d <= 1 : d >= 0;
59
+ }
60
+
61
+ if (index === YEAR_FIRST_DIGIT_INDEX) {
62
+ return d === 1 || d === MONTH_SEPARATOR_INDEX;
63
+ }
64
+
65
+ return true;
66
+ }
67
+
68
+ function filterValidDigits(raw: string): string {
69
+ let result = '';
70
+
71
+ for (let i = 0; i < raw.length && result.length < MAX_DIGITS; i += 1) {
72
+ if (isDigitValid(raw[i], result.length, result)) {
73
+ result += raw[i];
74
+ }
75
+ }
76
+
77
+ return result;
78
+ }
79
+
80
+ function extractDigits(date: Date | undefined): string {
81
+ if (!date) {
82
+ return '';
83
+ }
84
+
85
+ return dayjs(date).format(DATE_FORMAT).replace(/\D/g, '');
86
+ }
87
+
88
+ function buildFilledPortion(digits: string): string {
89
+ let result = '';
90
+
91
+ for (let i = 0; i < digits.length && i < MAX_DIGITS; i += 1) {
92
+ if (i === MONTH_SEPARATOR_INDEX || i === DAY_SEPARATOR_INDEX) {
93
+ result += '/';
94
+ }
95
+ result += digits[i];
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ function buildGuideSuffix(digits: string): string {
102
+ const filled = buildFilledPortion(digits);
103
+
104
+ return GUIDE_TEMPLATE.slice(filled.length);
105
+ }
106
+
107
+ function parseDate(digits: string): Date | undefined {
108
+ if (digits.length !== MAX_DIGITS) {
109
+ return undefined;
110
+ }
111
+
112
+ const masked = buildFilledPortion(digits);
113
+ const parsed = dayjs(masked, DATE_FORMAT, true);
114
+
115
+ if (parsed.isValid()) {
116
+ return parsed.toDate();
117
+ }
118
+
119
+ return undefined;
120
+ }
121
+
122
+ export function DateTextInput(props: Props) {
123
+ const { error, onChange, onValidationError, ref, renderRight, value } = props;
124
+ const [digits, setDigits] = useState(() => extractDigits(value));
125
+ const [isFocused, setIsFocused] = useState(false);
126
+ const [invalidDateError, setInvalidDateError] = useState<string | undefined>();
127
+ const inputRef = useRef<TextInput>(null);
128
+
129
+ useEffect(() => {
130
+ if (!ref) {
131
+ return;
132
+ }
133
+
134
+ if (typeof ref === 'function') {
135
+ ref(inputRef.current);
136
+ } else {
137
+ (ref as React.MutableRefObject<TextInput | null>).current = inputRef.current;
138
+ }
139
+ }, [ref]);
140
+
141
+ const filledPortion = buildFilledPortion(digits);
142
+ const guideSuffix = buildGuideSuffix(digits);
143
+ const parsedDate = parseDate(digits);
144
+ const displayError = invalidDateError || error;
145
+
146
+ const containerStyle: StyleProp<ViewStyle> = [
147
+ defaultInputContainerStyle,
148
+ tw`dark:border-divider dark:bg-surface items-center`,
149
+ focusedInputStyle(isFocused),
150
+ displayError && tw`border-red-500`,
151
+ ];
152
+
153
+ const handleChangeText = useCallback(
154
+ (text: string) => {
155
+ const rawDigits = text.replace(/\D/g, '');
156
+ const newDigits = filterValidDigits(rawDigits);
157
+ setDigits(newDigits);
158
+
159
+ if (newDigits.length === MAX_DIGITS) {
160
+ const masked = buildFilledPortion(newDigits);
161
+ const parsed = dayjs(masked, DATE_FORMAT, true);
162
+
163
+ if (parsed.isValid()) {
164
+ setInvalidDateError(undefined);
165
+ onValidationError?.(undefined);
166
+ onChange(parsed.toDate());
167
+
168
+ return;
169
+ }
170
+
171
+ const errorMsg = 'Please enter a valid date';
172
+ setInvalidDateError(errorMsg);
173
+ onValidationError?.(errorMsg);
174
+ } else {
175
+ setInvalidDateError(undefined);
176
+ onValidationError?.(undefined);
177
+ }
178
+
179
+ onChange(undefined);
180
+ },
181
+ [onChange, onValidationError],
182
+ );
183
+
184
+ const handleBlur = useCallback(() => {
185
+ setIsFocused(false);
186
+ }, []);
187
+
188
+ const handleFocus = useCallback(() => {
189
+ setIsFocused(true);
190
+ }, []);
191
+
192
+ const handlePress = useCallback(() => {
193
+ inputRef.current?.focus();
194
+ }, []);
195
+
196
+ return (
197
+ <Pressable style={containerStyle} onPress={handlePress}>
198
+ <TextInput
199
+ ref={inputRef}
200
+ keyboardType="number-pad"
201
+ style={[
202
+ defaultInputTextStyle,
203
+ tw`dark:text-foreground`,
204
+ { ...fontFamily, opacity: 0, position: 'absolute' },
205
+ ]}
206
+ value={digits}
207
+ onBlur={handleBlur}
208
+ onChangeText={handleChangeText}
209
+ onFocus={handleFocus}
210
+ />
211
+ <Typography style={[defaultInputTextStyle, tw`dark:text-foreground`, fontFamily]}>
212
+ {filledPortion}
213
+ <Typography style={[tw`text-placeholder`, { ...fontFamily }]}>{guideSuffix}</Typography>
214
+ </Typography>
215
+ {parsedDate && renderRight?.(parsedDate)}
216
+ </Pressable>
217
+ );
218
+ }
@@ -0,0 +1 @@
1
+ export * from './date-text-input.component';
@@ -9,5 +9,5 @@ type Props = DefaultComponentProps & {};
9
9
  export function Divider(props: Props) {
10
10
  const { style } = props;
11
11
 
12
- return <View style={[tw`h-[1px] w-full bg-gray-200`, style]}></View>;
12
+ return <View style={[tw`dark:bg-divider h-[1px] w-full bg-gray-200`, style]}></View>;
13
13
  }