Khởi tạo ban đầu

This commit is contained in:
Tran Anh Tuan
2025-11-28 16:59:57 +07:00
parent 2911be97b2
commit 4ba46a7df2
131 changed files with 28066 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-theme-context';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme();
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -0,0 +1,61 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { SymbolViewProps, SymbolWeight } from "expo-symbols";
import { ComponentProps } from "react";
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native";
type IconMapping = Record<
SymbolViewProps["name"],
ComponentProps<typeof MaterialIcons>["name"]
>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
gear: "settings",
"paperplane.fill": "send",
"chevron.left.forwardslash.chevron.right": "code",
"chevron.right": "chevron-right",
"ferry.fill": "directions-boat",
"map.fill": "map",
"chevron.down": "arrow-drop-down",
"chevron.up": "arrow-drop-up",
"exclamationmark.triangle.fill": "warning",
"book.closed.fill": "book",
"dot.radiowaves.left.and.right": "sensors",
xmark: "close",
pencil: "edit",
trash: "delete",
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return (
<MaterialIcons
color={color}
size={size}
name={MAPPING[name]}
style={style}
/>
);
}

578
components/ui/modal.tsx Normal file
View File

@@ -0,0 +1,578 @@
import { Ionicons } from "@expo/vector-icons";
import React, { ReactNode, useEffect, useState } from "react";
import {
ActivityIndicator,
Dimensions,
Pressable,
Modal as RNModal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
// Types
export interface ModalProps {
/** Whether the modal dialog is visible or not */
open?: boolean;
/** The modal dialog's title */
title?: ReactNode;
/** Whether a close (x) button is visible on top right or not */
closable?: boolean;
/** Custom close icon */
closeIcon?: ReactNode;
/** Whether to close the modal dialog when the mask (area outside the modal) is clicked */
maskClosable?: boolean;
/** Centered Modal */
centered?: boolean;
/** Width of the modal dialog */
width?: number | string;
/** Whether to apply loading visual effect for OK button or not */
confirmLoading?: boolean;
/** Text of the OK button */
okText?: string;
/** Text of the Cancel button */
cancelText?: string;
/** Button type of the OK button */
okType?: "primary" | "default" | "dashed" | "text" | "link";
/** Footer content, set as footer={null} when you don't need default buttons */
footer?: ReactNode | null;
/** Whether show mask or not */
mask?: boolean;
/** The z-index of the Modal */
zIndex?: number;
/** Specify a function that will be called when a user clicks the OK button */
onOk?: (e?: any) => void | Promise<void>;
/** Specify a function that will be called when a user clicks mask, close button on top right or Cancel button */
onCancel?: (e?: any) => void;
/** Callback when the animation ends when Modal is turned on and off */
afterOpenChange?: (open: boolean) => void;
/** Specify a function that will be called when modal is closed completely */
afterClose?: () => void;
/** Custom className */
className?: string;
/** Modal body content */
children?: ReactNode;
/** Whether to unmount child components on close */
destroyOnClose?: boolean;
/** The ok button props */
okButtonProps?: any;
/** The cancel button props */
cancelButtonProps?: any;
/** Whether support press esc to close */
keyboard?: boolean;
}
export interface ConfirmModalProps extends Omit<ModalProps, "open"> {
/** Type of the confirm modal */
type?: "info" | "success" | "error" | "warning" | "confirm";
/** Content */
content?: ReactNode;
/** Custom icon */
icon?: ReactNode;
}
// Modal Component
const Modal: React.FC<ModalProps> & {
info: (props: ConfirmModalProps) => ModalInstance;
success: (props: ConfirmModalProps) => ModalInstance;
error: (props: ConfirmModalProps) => ModalInstance;
warning: (props: ConfirmModalProps) => ModalInstance;
confirm: (props: ConfirmModalProps) => ModalInstance;
useModal: () => [
{
info: (props: ConfirmModalProps) => ModalInstance;
success: (props: ConfirmModalProps) => ModalInstance;
error: (props: ConfirmModalProps) => ModalInstance;
warning: (props: ConfirmModalProps) => ModalInstance;
confirm: (props: ConfirmModalProps) => ModalInstance;
},
ReactNode
];
} = ({
open = false,
title,
closable = true,
closeIcon,
maskClosable = true,
centered = false,
width = 520,
confirmLoading = false,
okText = "OK",
cancelText = "Cancel",
okType = "primary",
footer,
mask = true,
zIndex = 1000,
onOk,
onCancel,
afterOpenChange,
afterClose,
className,
children,
destroyOnClose = false,
okButtonProps,
cancelButtonProps,
keyboard = true,
}) => {
const [visible, setVisible] = useState(open);
const [loading, setLoading] = useState(false);
useEffect(() => {
setVisible(open);
if (afterOpenChange) {
afterOpenChange(open);
}
}, [open, afterOpenChange]);
const handleOk = async () => {
if (onOk) {
setLoading(true);
try {
await onOk();
// Không tự động đóng modal - để parent component quyết định
} catch (error) {
console.error("Modal onOk error:", error);
} finally {
setLoading(false);
}
} else {
setVisible(false);
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
} else {
// Nếu không có onCancel, tự động đóng modal
setVisible(false);
}
};
const handleMaskPress = () => {
if (maskClosable) {
handleCancel();
}
};
const handleRequestClose = () => {
if (keyboard) {
handleCancel();
}
};
useEffect(() => {
if (!visible && afterClose) {
const timer = setTimeout(() => {
afterClose();
}, 300); // Wait for animation to complete
return () => clearTimeout(timer);
}
}, [visible, afterClose]);
const renderFooter = () => {
if (footer === null) {
return null;
}
if (footer !== undefined) {
return <View style={styles.footer}>{footer}</View>;
}
return (
<View style={styles.footer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleCancel}
disabled={loading || confirmLoading}
{...cancelButtonProps}
>
<Text style={styles.cancelButtonText}>{cancelText}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
styles.okButton,
okType === "primary" && styles.primaryButton,
(loading || confirmLoading) && styles.disabledButton,
]}
onPress={handleOk}
disabled={loading || confirmLoading}
{...okButtonProps}
>
{loading || confirmLoading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.okButtonText}>{okText}</Text>
)}
</TouchableOpacity>
</View>
);
};
const modalWidth =
typeof width === "number" ? width : Dimensions.get("window").width * 0.9;
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleRequestClose}
statusBarTranslucent
>
<Pressable
style={[
styles.overlay,
centered && styles.centered,
{ zIndex },
!mask && styles.noMask,
]}
onPress={handleMaskPress}
>
<Pressable
style={[styles.modal, { width: modalWidth, maxWidth: "90%" }]}
onPress={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || closable) && (
<View style={styles.header}>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{closable && (
<TouchableOpacity
style={styles.closeButton}
onPress={handleCancel}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
{closeIcon || (
<Ionicons name="close" size={24} color="#666" />
)}
</TouchableOpacity>
)}
</View>
)}
{/* Body */}
<View style={styles.body}>
{(!destroyOnClose || visible) && children}
</View>
{/* Footer */}
{renderFooter()}
</Pressable>
</Pressable>
</RNModal>
);
};
// Confirm Modal Component
const ConfirmModal: React.FC<
ConfirmModalProps & { visible: boolean; onClose: () => void }
> = ({
visible,
onClose,
type = "confirm",
title,
content,
icon,
okText = "OK",
cancelText = "Cancel",
onOk,
onCancel,
...restProps
}) => {
const [loading, setLoading] = useState(false);
const getIcon = () => {
if (icon !== undefined) return icon;
const iconProps = { size: 24, style: { marginRight: 12 } };
switch (type) {
case "info":
return (
<Ionicons name="information-circle" color="#1890ff" {...iconProps} />
);
case "success":
return (
<Ionicons name="checkmark-circle" color="#52c41a" {...iconProps} />
);
case "error":
return <Ionicons name="close-circle" color="#ff4d4f" {...iconProps} />;
case "warning":
return <Ionicons name="warning" color="#faad14" {...iconProps} />;
default:
return <Ionicons name="help-circle" color="#1890ff" {...iconProps} />;
}
};
const handleOk = async () => {
if (onOk) {
setLoading(true);
try {
await onOk();
onClose();
} catch (error) {
console.error("Confirm modal onOk error:", error);
} finally {
setLoading(false);
}
} else {
onClose();
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
}
onClose();
};
return (
<Modal
open={visible}
title={title}
onOk={handleOk}
onCancel={type === "confirm" ? handleCancel : undefined}
okText={okText}
cancelText={cancelText}
confirmLoading={loading}
footer={
type === "confirm" ? undefined : (
<View style={styles.footer}>
<TouchableOpacity
style={[styles.button, styles.okButton, styles.primaryButton]}
onPress={handleOk}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.okButtonText}>{okText}</Text>
)}
</TouchableOpacity>
</View>
)
}
{...restProps}
>
<View style={styles.confirmContent}>
{getIcon()}
<Text style={styles.confirmText}>{content}</Text>
</View>
</Modal>
);
};
// Modal Instance
export interface ModalInstance {
destroy: () => void;
update: (config: ConfirmModalProps) => void;
}
// Container for imperatively created modals - Not used in React Native
// Static methods will return instance but won't render imperatively
// Use Modal.useModal() hook for proper context support
const createConfirmModal = (config: ConfirmModalProps): ModalInstance => {
console.warn(
"Modal static methods are not fully supported in React Native. Please use Modal.useModal() hook for better context support."
);
return {
destroy: () => {
console.warn(
"Modal.destroy() called but static modals are not supported in React Native"
);
},
update: (newConfig: ConfirmModalProps) => {
console.warn(
"Modal.update() called but static modals are not supported in React Native"
);
},
};
};
// Static methods
Modal.info = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "info" });
Modal.success = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "success" });
Modal.error = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "error" });
Modal.warning = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "warning" });
Modal.confirm = (props: ConfirmModalProps) =>
createConfirmModal({ ...props, type: "confirm" });
// useModal hook
Modal.useModal = () => {
const [modals, setModals] = useState<ReactNode[]>([]);
const createModal = (
config: ConfirmModalProps,
type: ConfirmModalProps["type"]
) => {
const id = `modal-${Date.now()}-${Math.random()}`;
const destroy = () => {
setModals((prev) => prev.filter((modal: any) => modal.key !== id));
};
const update = (newConfig: ConfirmModalProps) => {
setModals((prev) =>
prev.map((modal: any) =>
modal.key === id ? (
<ConfirmModal
key={id}
visible={true}
onClose={destroy}
{...config}
{...newConfig}
type={type}
/>
) : (
modal
)
)
);
};
const modalElement = (
<ConfirmModal
key={id}
visible={true}
onClose={destroy}
{...config}
type={type}
/>
);
setModals((prev) => [...prev, modalElement]);
return { destroy, update };
};
const modalMethods = {
info: (props: ConfirmModalProps) => createModal(props, "info"),
success: (props: ConfirmModalProps) => createModal(props, "success"),
error: (props: ConfirmModalProps) => createModal(props, "error"),
warning: (props: ConfirmModalProps) => createModal(props, "warning"),
confirm: (props: ConfirmModalProps) => createModal(props, "confirm"),
};
const contextHolder = <>{modals}</>;
return [modalMethods, contextHolder];
};
// Styles
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.45)",
justifyContent: "flex-start",
paddingTop: 100,
alignItems: "center",
},
centered: {
justifyContent: "center",
paddingTop: 0,
},
noMask: {
backgroundColor: "transparent",
},
modal: {
backgroundColor: "#fff",
borderRadius: 8,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
maxHeight: "90%",
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 24,
paddingTop: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
title: {
fontSize: 18,
fontWeight: "600",
color: "#000",
flex: 1,
},
closeButton: {
padding: 4,
marginLeft: 12,
},
body: {
paddingHorizontal: 24,
paddingVertical: 20,
},
footer: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
paddingHorizontal: 24,
paddingBottom: 20,
paddingTop: 12,
gap: 8,
},
button: {
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 6,
minWidth: 70,
alignItems: "center",
justifyContent: "center",
height: 36,
},
cancelButton: {
backgroundColor: "#fff",
borderWidth: 1,
borderColor: "#d9d9d9",
},
cancelButtonText: {
color: "#000",
fontSize: 14,
fontWeight: "500",
},
okButton: {
backgroundColor: "#1890ff",
},
primaryButton: {
backgroundColor: "#1890ff",
},
okButtonText: {
color: "#fff",
fontSize: 14,
fontWeight: "500",
},
disabledButton: {
opacity: 0.6,
},
confirmContent: {
flexDirection: "row",
alignItems: "flex-start",
},
confirmText: {
flex: 1,
fontSize: 14,
color: "#000",
lineHeight: 22,
},
});
export default Modal;

View File

@@ -0,0 +1,262 @@
import { Ionicons } from "@expo/vector-icons";
import type { ComponentProps } from "react";
import { useEffect, useRef, useState } from "react";
import {
Animated,
OpaqueColorValue,
Pressable,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from "react-native";
const SIZE_PRESETS = {
sm: { width: 64, height: 32 },
md: { width: 80, height: 40 },
lg: { width: 96, height: 48 },
} as const;
type SwitchSize = keyof typeof SIZE_PRESETS;
const DEFAULT_TOGGLE_DURATION = 400;
// Default both backgrounds to a grey tone when not provided
const DEFAULT_INACTIVE_BG = "#D3DAD9";
const DEFAULT_ACTIVE_BG = "#D3DAD9";
const PRESSED_SCALE = 0.96;
const PRESS_FEEDBACK_DURATION = 120;
type IoniconName = ComponentProps<typeof Ionicons>["name"];
type SliceSwitchProps = {
size?: SwitchSize;
leftIcon?: IoniconName;
leftIconColor?: string | OpaqueColorValue | undefined;
rightIconColor?: string | OpaqueColorValue | undefined;
rightIcon?: IoniconName;
duration?: number;
activeBackgroundColor?: string;
inactiveBackgroundColor?: string;
inactiveOverlayColor?: string;
activeOverlayColor?: string;
style?: StyleProp<ViewStyle>;
onChange?: (value: boolean) => void;
value?: boolean;
};
const SliceSwitch = ({
size = "md",
leftIcon,
rightIcon,
duration,
activeBackgroundColor = DEFAULT_ACTIVE_BG,
inactiveBackgroundColor = DEFAULT_INACTIVE_BG,
leftIconColor = "#fff",
rightIconColor = "#fff",
inactiveOverlayColor = "#000",
activeOverlayColor = "#000",
style,
onChange,
value,
}: SliceSwitchProps) => {
const { width: containerWidth, height: containerHeight } =
SIZE_PRESETS[size] ?? SIZE_PRESETS.md;
const animationDuration = Math.max(duration ?? DEFAULT_TOGGLE_DURATION, 0);
const [isOn, setIsOn] = useState(value ?? false);
const [bgOn, setBgOn] = useState(value ?? false);
const progress = useRef(new Animated.Value(value ? 1 : 0)).current;
const pressScale = useRef(new Animated.Value(1)).current;
const overlayTranslateX = useRef(
new Animated.Value(value ? containerWidth / 2 : 0)
).current;
const listenerIdRef = useRef<string | number | null>(null);
// Sync with external value prop if provided
useEffect(() => {
if (value !== undefined && value !== isOn) {
animateToValue(value);
}
}, [value]);
const animateToValue = (next: boolean) => {
const targetValue = next ? 1 : 0;
const overlayTarget = next ? containerWidth / 2 : 0;
progress.stopAnimation();
overlayTranslateX.stopAnimation();
if (animationDuration <= 0) {
progress.setValue(targetValue);
overlayTranslateX.setValue(overlayTarget);
setIsOn(next);
setBgOn(next);
return;
}
setIsOn(next);
Animated.parallel([
Animated.timing(progress, {
toValue: targetValue,
duration: animationDuration,
useNativeDriver: true,
}),
Animated.timing(overlayTranslateX, {
toValue: overlayTarget,
duration: animationDuration,
useNativeDriver: true,
}),
]).start(() => {
setBgOn(next);
});
// Remove any previous listener
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
// Swap image & background exactly at 50% progress
let swapped = false;
listenerIdRef.current = progress.addListener(({ value }) => {
if (swapped) return;
if (next && value >= 0.5) {
swapped = true;
setBgOn(next);
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
}
if (!next && value <= 0.5) {
swapped = true;
setBgOn(next);
if (listenerIdRef.current != null) {
progress.removeListener(listenerIdRef.current as string);
listenerIdRef.current = null;
}
}
});
};
const handleToggle = () => {
const next = !isOn;
if (value === undefined) {
animateToValue(next);
}
onChange?.(next);
};
const handlePressIn = () => {
pressScale.stopAnimation();
Animated.timing(pressScale, {
toValue: PRESSED_SCALE,
duration: PRESS_FEEDBACK_DURATION,
useNativeDriver: true,
}).start();
};
const handlePressOut = () => {
pressScale.stopAnimation();
Animated.timing(pressScale, {
toValue: 1,
duration: PRESS_FEEDBACK_DURATION,
useNativeDriver: true,
}).start();
};
return (
<Pressable
onPress={handleToggle}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
accessibilityRole="switch"
accessibilityState={{ checked: isOn }}
style={[styles.pressable, style]}
>
<Animated.View
style={[
styles.shadowWrapper,
{
transform: [{ scale: pressScale }],
width: containerWidth,
height: containerHeight,
borderRadius: containerHeight / 2,
},
]}
>
<View
style={[
styles.container,
{
flexDirection: "row",
justifyContent: "space-between",
borderRadius: containerHeight / 2,
backgroundColor: bgOn
? activeBackgroundColor
: inactiveBackgroundColor,
},
]}
>
<Animated.View
style={{
position: "absolute",
width: containerWidth / 2,
height: containerHeight * 0.95,
top: containerHeight * 0.01,
left: 0,
borderRadius: containerHeight * 0.95 / 2,
zIndex: 10,
backgroundColor: bgOn ? activeOverlayColor : inactiveOverlayColor,
transform: [{ translateX: overlayTranslateX }],
}}
/>
<View className="h-full w-1/2 items-center justify-center ">
<Ionicons
name={leftIcon ?? "sunny"}
size={20}
color={leftIconColor ?? "#fff"}
/>
</View>
<View className="h-full w-1/2 items-center justify-center ">
<Ionicons
name={rightIcon ?? "moon"}
size={20}
color={rightIconColor ?? "#fff"}
/>
</View>
</View>
</Animated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
pressable: {
alignSelf: "flex-start",
},
shadowWrapper: {
justifyContent: "center",
position: "relative",
shadowColor: "#000",
shadowOpacity: 0.15,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 6,
elevation: 6,
backgroundColor: "transparent",
},
container: {
flex: 1,
justifyContent: "center",
position: "relative",
overflow: "hidden",
},
knob: {
position: "absolute",
top: 0,
left: 0,
},
});
export default SliceSwitch;