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.
- package/lib/index.cjs +27 -27
- package/package.json +1 -1
- package/templates/shared/apps/mobile/src/app/index.tsx +13 -8
- package/templates/shared/apps/mobile/src/components/atoms/AlertManager/alert-manager.component.tsx +134 -0
- package/templates/shared/apps/mobile/src/components/atoms/AlertManager/alert-manager.types.ts +18 -0
- package/templates/shared/apps/mobile/src/components/atoms/AlertManager/alert.service.ts +27 -0
- package/templates/shared/apps/mobile/src/components/atoms/AlertManager/index.ts +3 -0
- package/templates/shared/apps/mobile/src/components/atoms/BottomSheet/bottom-sheet.component.tsx +14 -8
- package/templates/shared/apps/mobile/src/components/atoms/Button/button.component.tsx +1 -1
- package/templates/shared/apps/mobile/src/components/atoms/DateModalInput/date-modal-input.component.tsx +69 -0
- package/templates/shared/apps/mobile/src/components/atoms/DateModalInput/index.ts +1 -0
- package/templates/shared/apps/mobile/src/components/atoms/DatePicker/date-picker.component.tsx +44 -0
- package/templates/shared/apps/mobile/src/components/atoms/DatePicker/index.ts +1 -0
- package/templates/shared/apps/mobile/src/components/atoms/DateTextInput/date-text-input.component.tsx +218 -0
- package/templates/shared/apps/mobile/src/components/atoms/DateTextInput/index.ts +1 -0
- package/templates/shared/apps/mobile/src/components/atoms/Divider/divider-component.tsx +1 -1
- package/templates/shared/apps/mobile/src/components/atoms/GradientBackground/gradient-background.component.tsx +45 -0
- package/templates/shared/apps/mobile/src/components/atoms/GradientBackground/index.ts +1 -0
- package/templates/shared/apps/mobile/src/components/atoms/InputLayout/input-layout.component.tsx +12 -4
- package/templates/shared/apps/mobile/src/components/atoms/KeyboardAccessory/keyboard-accessory.component.tsx +6 -3
- package/templates/shared/apps/mobile/src/components/atoms/Modal/modal.component.tsx +2 -0
- package/templates/shared/apps/mobile/src/components/atoms/ScreenLoader/screen-loader.component.tsx +6 -1
- package/templates/shared/apps/mobile/src/components/atoms/SelectDropdown/index.ts +1 -0
- package/templates/shared/apps/mobile/src/components/atoms/SelectDropdown/select-dropdown.component.tsx +223 -0
- package/templates/shared/apps/mobile/src/components/atoms/Skeleton/skeleton.component.tsx +1 -1
- package/templates/shared/apps/mobile/src/components/atoms/TextInput/bottom-sheet-text-input.component.tsx +4 -3
- package/templates/shared/apps/mobile/src/components/atoms/TextInput/text-input.component.tsx +8 -4
- package/templates/shared/apps/mobile/src/components/atoms/ThemeManager/index.ts +1 -0
- package/templates/shared/apps/mobile/src/components/atoms/ThemeManager/theme-manager.component.tsx +27 -0
- package/templates/shared/apps/mobile/src/components/atoms/ToastManager/index.ts +3 -0
- package/templates/shared/apps/mobile/src/components/atoms/ToastManager/toast-manager.component.tsx +109 -0
- package/templates/shared/apps/mobile/src/components/atoms/ToastManager/toast-manager.types.ts +10 -0
- package/templates/shared/apps/mobile/src/components/atoms/ToastManager/toast.service.ts +27 -0
- package/templates/shared/apps/mobile/src/components/atoms/Typography/typography.component.tsx +1 -1
- package/templates/shared/apps/mobile/src/components/atoms/index.ts +8 -0
- package/templates/shared/apps/mobile/src/components/molecules/BackButton/back-button.component.tsx +1 -1
- package/templates/shared/apps/mobile/src/components/molecules/ScreenContainer/screen-container.component.tsx +2 -22
- package/templates/shared/apps/mobile/src/components/molecules/ScreenHeader/screen-header.component.tsx +2 -2
- package/templates/shared/apps/mobile/src/hooks/index.ts +1 -0
- package/templates/shared/apps/mobile/src/hooks/usePushNotifications.hook.ts +104 -0
- package/templates/shared/apps/mobile/src/hooks/useToggleDarkMode.hook.tsx +24 -2
- package/templates/shared/apps/mobile/src/icons/alert-triangle.svg +5 -0
- package/templates/shared/apps/mobile/src/icons/check-circle.svg +4 -0
- package/templates/shared/apps/mobile/src/icons/chevron-down.svg +1 -0
- package/templates/shared/apps/mobile/src/icons/chevron-right.svg +1 -0
- package/templates/shared/apps/mobile/src/icons/index.ts +18 -1
- package/templates/shared/apps/mobile/src/icons/info.svg +5 -0
- package/templates/shared/apps/mobile/src/icons/x-circle.svg +5 -0
- package/templates/shared/apps/mobile/src/routes/index.tsx +19 -14
- package/templates/shared/apps/mobile/src/screens/LandingScreen/landing.screen.tsx +232 -8
- package/templates/shared/apps/mobile/src/stores/local-storage.store.ts +9 -5
- package/templates/shared/apps/mobile/src/stores/theme.slice.ts +15 -0
- package/templates/shared/apps/mobile/src/stores/user.slice.ts +5 -1
- package/templates/shared/apps/mobile/src/tailwind/index.ts +3 -3
- package/templates/shared/apps/mobile/tailwind.config.js +14 -0
- package/templates/shared/patches/react-native-animatable+1.4.0.patch +71 -0
package/package.json
CHANGED
|
@@ -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([
|
|
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
|
-
|
|
30
|
+
<>
|
|
31
|
+
<ThemeManager />
|
|
32
|
+
<ApplicationRoutes />
|
|
33
|
+
<AlertManager />
|
|
34
|
+
<ToastManager />
|
|
35
|
+
</>
|
|
31
36
|
</StorageManager>
|
|
32
37
|
</PersistQueryClientProvider>
|
|
33
38
|
</KeyboardProvider>
|
package/templates/shared/apps/mobile/src/components/atoms/AlertManager/alert-manager.component.tsx
ADDED
|
@@ -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 };
|
package/templates/shared/apps/mobile/src/components/atoms/BottomSheet/bottom-sheet.component.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
}
|
|
@@ -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';
|
package/templates/shared/apps/mobile/src/components/atoms/DatePicker/date-picker.component.tsx
ADDED
|
@@ -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
|
}
|