Compare commits

..

3 Commits

Author SHA1 Message Date
Tran Anh Tuan
d8874fbe60 uncomment 2025-11-19 15:05:59 +07:00
00fd53bbd4 fix config theme system 2025-11-19 14:52:12 +07:00
Tran Anh Tuan
742d8f6bcc remove gluestackk-ui 2025-11-19 14:23:17 +07:00
22 changed files with 1471 additions and 2631 deletions

View File

@@ -3,5 +3,6 @@
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
},
"postman.settings.dotenv-detection-notification-visibility": false
}

409
MODAL_USAGE.md Normal file
View File

@@ -0,0 +1,409 @@
# Modal Component
Modal component tương tự như Modal của Ant Design, được tạo cho React Native/Expo.
## Cài đặt
Component này sử dụng `@expo/vector-icons` cho các icon. Đảm bảo bạn đã cài đặt:
```bash
npx expo install @expo/vector-icons
```
## Import
```tsx
import Modal from "@/components/ui/modal";
```
## Các tính năng chính
### 1. Basic Modal
```tsx
import React, { useState } from "react";
import { View, Text, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function BasicExample() {
const [open, setOpen] = useState(false);
return (
<View>
<Button title="Open Modal" onPress={() => setOpen(true)} />
<Modal
open={open}
title="Basic Modal"
onOk={() => {
console.log("OK clicked");
setOpen(false);
}}
onCancel={() => setOpen(false)}
>
<Text>Some contents...</Text>
<Text>Some contents...</Text>
<Text>Some contents...</Text>
</Modal>
</View>
);
}
```
### 2. Async Close (với confirmLoading)
```tsx
import React, { useState } from "react";
import { View, Text, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function AsyncExample() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleOk = async () => {
setLoading(true);
// Giả lập API call
await new Promise((resolve) => setTimeout(resolve, 2000));
setLoading(false);
setOpen(false);
};
return (
<View>
<Button title="Open Modal" onPress={() => setOpen(true)} />
<Modal
open={open}
title="Async Modal"
confirmLoading={loading}
onOk={handleOk}
onCancel={() => setOpen(false)}
>
<Text>Modal will be closed after 2 seconds when you click OK.</Text>
</Modal>
</View>
);
}
```
### 3. Customized Footer
```tsx
import React, { useState } from "react";
import { View, Text, Button, TouchableOpacity, StyleSheet } from "react-native";
import Modal from "@/components/ui/modal";
export default function CustomFooterExample() {
const [open, setOpen] = useState(false);
return (
<View>
<Button title="Open Modal" onPress={() => setOpen(true)} />
<Modal
open={open}
title="Custom Footer Modal"
onCancel={() => setOpen(false)}
footer={
<View style={styles.customFooter}>
<TouchableOpacity
style={styles.customButton}
onPress={() => setOpen(false)}
>
<Text style={styles.buttonText}>Return</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.customButton, styles.submitButton]}
onPress={() => {
console.log("Submit");
setOpen(false);
}}
>
<Text style={[styles.buttonText, styles.submitText]}>Submit</Text>
</TouchableOpacity>
</View>
}
>
<Text>Custom footer buttons</Text>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
customFooter: {
flexDirection: "row",
gap: 8,
},
customButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 6,
borderWidth: 1,
borderColor: "#d9d9d9",
},
submitButton: {
backgroundColor: "#1890ff",
borderColor: "#1890ff",
},
buttonText: {
fontSize: 14,
color: "#000",
},
submitText: {
color: "#fff",
},
});
```
### 4. No Footer
```tsx
<Modal
open={open}
title="Modal Without Footer"
footer={null}
onCancel={() => setOpen(false)}
>
<Text>Modal content without footer buttons</Text>
</Modal>
```
### 5. Centered Modal
```tsx
<Modal
open={open}
title="Centered Modal"
centered
onOk={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<Text>This modal is centered on the screen</Text>
</Modal>
```
### 6. Custom Width
```tsx
<Modal
open={open}
title="Custom Width Modal"
width={700}
onOk={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<Text>This modal has custom width</Text>
</Modal>
```
### 7. Confirm Modal với useModal Hook
**Đây là cách khuyến nghị sử dụng trong React Native để có context đầy đủ:**
```tsx
import React from "react";
import { View, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function HookExample() {
const [modal, contextHolder] = Modal.useModal();
const showConfirm = () => {
modal.confirm({
title: "Do you want to delete these items?",
content:
"When clicked the OK button, this dialog will be closed after 1 second",
onOk: async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log("OK");
},
onCancel: () => {
console.log("Cancel");
},
});
};
const showInfo = () => {
modal.info({
title: "This is a notification message",
content: "Some additional information...",
});
};
const showSuccess = () => {
modal.success({
title: "Success",
content: "Operation completed successfully!",
});
};
const showError = () => {
modal.error({
title: "Error",
content: "Something went wrong!",
});
};
const showWarning = () => {
modal.warning({
title: "Warning",
content: "This is a warning message!",
});
};
return (
<View style={{ padding: 20, gap: 10 }}>
{/* contextHolder phải được đặt trong component */}
{contextHolder}
<Button title="Confirm" onPress={showConfirm} />
<Button title="Info" onPress={showInfo} />
<Button title="Success" onPress={showSuccess} />
<Button title="Error" onPress={showError} />
<Button title="Warning" onPress={showWarning} />
</View>
);
}
```
### 8. Update Modal Instance
```tsx
import React from "react";
import { View, Button } from "react-native";
import Modal from "@/components/ui/modal";
export default function UpdateExample() {
const [modal, contextHolder] = Modal.useModal();
const showModal = () => {
const instance = modal.success({
title: "Loading...",
content: "Please wait...",
});
// Update after 2 seconds
setTimeout(() => {
instance.update({
title: "Success!",
content: "Operation completed successfully!",
});
}, 2000);
// Close after 4 seconds
setTimeout(() => {
instance.destroy();
}, 4000);
};
return (
<View>
{contextHolder}
<Button title="Show Updating Modal" onPress={showModal} />
</View>
);
}
```
## API
### Modal Props
| Prop | Type | Default | Description |
| --------------- | ----------------------------- | --------- | ------------------------------------------------------------ |
| open | boolean | false | Whether the modal dialog is visible or not |
| title | ReactNode | - | The modal dialog's title |
| closable | boolean | true | Whether a close (x) button is visible on top right or not |
| closeIcon | ReactNode | - | Custom close icon |
| maskClosable | boolean | true | Whether to close the modal dialog when the mask is clicked |
| centered | boolean | false | Centered Modal |
| width | number \| string | 520 | Width of the modal dialog |
| confirmLoading | boolean | false | Whether to apply loading visual effect for OK button |
| okText | string | 'OK' | Text of the OK button |
| cancelText | string | 'Cancel' | Text of the Cancel button |
| okType | 'primary' \| 'default' | 'primary' | Button type of the OK button |
| footer | ReactNode \| null | - | Footer content, set as footer={null} to hide default buttons |
| mask | boolean | true | Whether show mask or not |
| zIndex | number | 1000 | The z-index of the Modal |
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK button |
| onCancel | (e?) => void | - | Callback when clicking cancel button or close icon |
| afterOpenChange | (open: boolean) => void | - | Callback when animation ends |
| afterClose | () => void | - | Callback when modal is closed completely |
| destroyOnClose | boolean | false | Whether to unmount child components on close |
| keyboard | boolean | true | Whether support press back button to close (Android) |
### Modal.useModal()
Khi bạn cần sử dụng Context, bạn có thể dùng `Modal.useModal()` để tạo `contextHolder` và chèn vào children. Modal được tạo bởi hooks sẽ có tất cả context nơi `contextHolder` được đặt.
**Returns:** `[modalMethods, contextHolder]`
- `modalMethods`: Object chứa các methods
- `info(config)`: Show info modal
- `success(config)`: Show success modal
- `error(config)`: Show error modal
- `warning(config)`: Show warning modal
- `confirm(config)`: Show confirm modal
- `contextHolder`: React element cần được render trong component tree
### Modal Methods Config
| Prop | Type | Default | Description |
| ---------- | -------------------------------------------------------- | --------- | ----------------------------- |
| type | 'info' \| 'success' \| 'error' \| 'warning' \| 'confirm' | 'confirm' | Type of the modal |
| title | ReactNode | - | Title |
| content | ReactNode | - | Content |
| icon | ReactNode | - | Custom icon |
| okText | string | 'OK' | Text of the OK button |
| cancelText | string | 'Cancel' | Text of the Cancel button |
| onOk | (e?) => void \| Promise<void> | - | Callback when clicking OK |
| onCancel | (e?) => void | - | Callback when clicking Cancel |
### Modal Instance
Modal instance được trả về bởi `Modal.useModal()`:
```tsx
interface ModalInstance {
destroy: () => void;
update: (config: ConfirmModalProps) => void;
}
```
## Lưu ý
1. **React Native Limitations**: Các static methods như `Modal.info()`, `Modal.confirm()` gọi trực tiếp (không qua hook) không được hỗ trợ đầy đủ trong React Native do không thể render imperatively. Hãy sử dụng `Modal.useModal()` hook thay thế.
2. **Context Support**: Khi cần sử dụng Context (như Redux, Theme Context), bắt buộc phải dùng `Modal.useModal()` hook và đặt `contextHolder` trong component tree.
3. **Animation**: Modal sử dụng React Native's built-in Modal với `animationType="fade"`.
4. **Icons**: Component sử dụng `@expo/vector-icons` (Ionicons). Đảm bảo đã cài đặt package này.
5. **Keyboard**: Prop `keyboard` trong React Native chỉ hoạt động với nút back của Android (không có ESC key như web).
## So sánh với Ant Design Modal
| Feature | Ant Design (Web) | This Component (RN) |
| --------------------------- | ---------------- | --------------------------- |
| Basic Modal | ✅ | ✅ |
| Centered | ✅ | ✅ |
| Custom Footer | ✅ | ✅ |
| Confirm Dialog | ✅ | ✅ (via useModal) |
| Info/Success/Error/Warning | ✅ | ✅ (via useModal) |
| Async close | ✅ | ✅ |
| Custom width | ✅ | ✅ |
| Mask closable | ✅ | ✅ |
| Keyboard close | ✅ (ESC) | ✅ (Back button on Android) |
| Static methods without hook | ✅ | ⚠️ (Limited support) |
| useModal hook | ✅ | ✅ (Recommended) |
| Draggable | ✅ | ❌ (Not applicable) |
| destroyAll() | ✅ | ❌ |
## License
MIT

View File

@@ -48,3 +48,35 @@ Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
## Build app
- Add eas.json file to root folder and add this:
```
{
"cli": {
"version": ">= 16.27.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"android": {
"buildType": "apk"
},
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
```

View File

@@ -12,8 +12,6 @@
"supportsTablet": true,
"infoPlist": {
"CFBundleLocalizations": [
"en",
"vi",
"en",
"vi"
]
@@ -80,6 +78,12 @@
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "d4ef1318-5427-4c96-ad88-97ec117829cc"
}
}
}
}

View File

@@ -1,7 +1,7 @@
import {
DarkTheme,
DefaultTheme,
ThemeProvider as NavigationThemeProvider,
ThemeProvider,
} from "@react-navigation/native";
import { Stack, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
@@ -9,27 +9,27 @@ import { useEffect } from "react";
import "react-native-reanimated";
// import Toast from "react-native-toast-message";
import { GluestackUIProvider } from "@/components/ui/gluestack-ui-provider/gluestack-ui-provider";
// import { toastConfig } from "@/config";
import { toastConfig } from "@/config";
import { setRouterInstance } from "@/config/auth";
import "@/global.css";
import { ThemeProvider, useThemeContext } from "@/hooks/use-theme-context";
import { I18nProvider } from "@/hooks/use-i18n";
import { ThemeProvider as AppThemeProvider } from "@/hooks/use-theme-context";
import { useColorScheme } from "react-native";
import Toast from "react-native-toast-message";
import "../global.css";
function AppContent() {
const { colorScheme } = useThemeContext();
// const { colorScheme } = useThemeContext();
const router = useRouter();
const colorScheme = useColorScheme();
console.log("Color Scheme: ", colorScheme);
useEffect(() => {
setRouterInstance(router);
}, [router]);
return (
<GluestackUIProvider mode={colorScheme}>
<NavigationThemeProvider
value={colorScheme === "dark" ? DarkTheme : DefaultTheme}
>
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack
screenOptions={{ headerShown: false }}
initialRouteName="auth/login"
@@ -57,17 +57,16 @@ function AppContent() {
</Stack>
<StatusBar style="auto" />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</NavigationThemeProvider>
</GluestackUIProvider>
</ThemeProvider>
);
}
export default function RootLayout() {
return (
<I18nProvider>
<ThemeProvider>
<AppThemeProvider>
<AppContent />
</ThemeProvider>
</AppThemeProvider>
</I18nProvider>
);
}

View File

@@ -6,8 +6,13 @@ import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import SliceSwitch from "@/components/ui/slice-switch";
import { DOMAIN, TOKEN } from "@/constants";
import { Colors } from "@/constants/theme";
import { login } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n";
import {
ColorScheme as ThemeColorScheme,
useTheme,
} from "@/hooks/use-theme-context";
import { showErrorToast, showWarningToast } from "@/services/toast_service";
import {
getStorageItem,
@@ -17,7 +22,7 @@ import {
import { parseJwtToken } from "@/utils/token";
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Image,
@@ -38,6 +43,13 @@ export default function LoginScreen() {
const [showPassword, setShowPassword] = useState(false);
const [isShowingQRScanner, setIsShowingQRScanner] = useState(false);
const { t, setLocale, locale } = useI18n();
const { colors, colorScheme } = useTheme();
const styles = useMemo(
() => createStyles(colors, colorScheme),
[colors, colorScheme]
);
const placeholderColor = colors.textSecondary;
const buttonTextColor = colorScheme === "dark" ? colors.text : colors.surface;
const [isVNLang, setIsVNLang] = useState(false);
const checkLogin = useCallback(async () => {
@@ -154,7 +166,10 @@ export default function LoginScreen() {
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<ScrollView contentContainerStyle={styles.scrollContainer}>
<ScrollView
style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={styles.scrollContainer}
>
<ThemedView style={styles.container}>
{/* Header */}
<View style={styles.headerContainer}>
@@ -177,7 +192,7 @@ export default function LoginScreen() {
<TextInput
style={styles.input}
placeholder={t("auth.username_placeholder")}
placeholderTextColor="#999"
placeholderTextColor={placeholderColor}
value={username}
onChangeText={setUsername}
editable={!loading}
@@ -192,7 +207,7 @@ export default function LoginScreen() {
<TextInput
style={styles.input}
placeholder={t("auth.password_placeholder")}
placeholderTextColor="#999"
placeholderTextColor={placeholderColor}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
@@ -217,7 +232,7 @@ export default function LoginScreen() {
<Ionicons
name={showPassword ? "eye-off" : "eye"}
size={22}
color="#666"
color={colors.icon}
/>
</TouchableOpacity>
</View>
@@ -241,9 +256,13 @@ export default function LoginScreen() {
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" size="small" />
<ActivityIndicator color={buttonTextColor} size="small" />
) : (
<Text style={styles.loginButtonText}>{t("auth.login")}</Text>
<Text
style={[styles.loginButtonText, { color: buttonTextColor }]}
>
{t("auth.login")}
</Text>
)}
</TouchableOpacity>
@@ -252,10 +271,10 @@ export default function LoginScreen() {
flex: 1,
paddingVertical: 10,
marginTop: 0,
borderColor: "#ddd",
borderColor: colors.border,
borderWidth: 1,
borderRadius: 8,
backgroundColor: "transparent",
backgroundColor: colors.surface,
justifyContent: "center",
alignItems: "center",
}}
@@ -265,7 +284,7 @@ export default function LoginScreen() {
<MaterialIcons
name="qr-code-scanner"
size={28}
color="#007AFF"
color={colors.primary}
/>
</TouchableOpacity>
</View>
@@ -282,13 +301,17 @@ export default function LoginScreen() {
<SliceSwitch
size="sm"
leftIcon="moon"
leftIconColor="white"
leftIconColor={
colorScheme === "dark" ? colors.background : colors.surface
}
rightIcon="sunny"
rightIconColor="orange"
activeBackgroundColor="black"
inactiveBackgroundColor="white"
inactiveOverlayColor="black"
activeOverlayColor="white"
rightIconColor={
colorScheme === "dark" ? colors.warning : "orange"
}
activeBackgroundColor={colors.text}
inactiveBackgroundColor={colors.surface}
inactiveOverlayColor={colors.textSecondary}
activeOverlayColor={colors.background}
/>
</View>
@@ -310,10 +333,12 @@ export default function LoginScreen() {
);
}
const styles = StyleSheet.create({
const createStyles = (colors: typeof Colors.light, scheme: ThemeColorScheme) =>
StyleSheet.create({
scrollContainer: {
flexGrow: 1,
justifyContent: "center",
backgroundColor: colors.background,
},
container: {
flex: 1,
@@ -350,16 +375,16 @@ const styles = StyleSheet.create({
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderColor: colors.border,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
backgroundColor: "#f5f5f5",
color: "#000",
backgroundColor: colors.surface,
color: colors.text,
},
loginButton: {
backgroundColor: "#007AFF",
backgroundColor: colors.primary,
paddingVertical: 14,
borderRadius: 8,
alignItems: "center",
@@ -369,7 +394,6 @@ const styles = StyleSheet.create({
opacity: 0.6,
},
loginButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
@@ -381,7 +405,7 @@ const styles = StyleSheet.create({
fontSize: 14,
},
linkText: {
color: "#007AFF",
color: colors.primary,
fontWeight: "600",
},
copyrightContainer: {
@@ -392,12 +416,12 @@ const styles = StyleSheet.create({
fontSize: 12,
opacity: 0.6,
textAlign: "center",
color: colors.textSecondary,
},
languageSwitcherContainer: {
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: 24,
gap: 20,
@@ -417,22 +441,22 @@ const styles = StyleSheet.create({
paddingVertical: 10,
paddingHorizontal: 12,
borderWidth: 1,
borderColor: "#ddd",
borderColor: colors.border,
borderRadius: 8,
alignItems: "center",
backgroundColor: "transparent",
backgroundColor: colors.surface,
},
languageButtonActive: {
backgroundColor: "#007AFF",
borderColor: "#007AFF",
backgroundColor: colors.primary,
borderColor: colors.primary,
},
languageButtonText: {
fontSize: 14,
fontWeight: "500",
color: "#666",
color: colors.textSecondary,
},
languageButtonTextActive: {
color: "#fff",
color: scheme === "dark" ? colors.text : colors.surface,
fontWeight: "600",
},
});
});

View File

@@ -8,24 +8,10 @@ import { showErrorToast } from "@/services/toast_service";
import { sosMessage } from "@/utils/sosUtils";
import { MaterialIcons } from "@expo/vector-icons";
import { useEffect, useState } from "react";
import {
FlatList,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { Button, ButtonText } from "../ui/gluestack-ui-provider/button";
import {
Modal,
ModalBackdrop,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from "../ui/gluestack-ui-provider/modal";
import { StyleSheet, Text, TextInput, View } from "react-native";
import IconButton from "../IconButton";
import Select from "../Select";
import Modal from "../ui/modal";
const SosButton = () => {
const [sosData, setSosData] = useState<Model.SosResponse | null>();
@@ -34,17 +20,23 @@ const SosButton = () => {
null
);
const [customMessage, setCustomMessage] = useState("");
const [showDropdown, setShowDropdown] = useState(false);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const { t } = useI18n();
const sosOptions = [
...sosMessage.map((msg) => ({ ma: msg.ma, moTa: msg.moTa })),
{ ma: 999, moTa: "Khác" },
...sosMessage.map((msg) => ({
ma: msg.ma,
moTa: msg.moTa,
label: msg.moTa,
value: msg.ma,
})),
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
];
const getSosData = async () => {
try {
const response = await queryGetSos();
// console.log("SoS ResponseL: ", response);
setSosData(response.data);
} catch (error) {
console.error("Failed to fetch SOS data:", error);
@@ -58,8 +50,6 @@ const SosButton = () => {
const validateForm = () => {
const newErrors: { [key: string]: string } = {};
// Không cần validate sosMessage vì luôn có default value (11)
if (selectedSosMessage === 999 && customMessage.trim() === "") {
newErrors.customMessage = t("home.sos.statusRequired");
}
@@ -69,7 +59,11 @@ const SosButton = () => {
};
const handleConfirmSos = async () => {
if (validateForm()) {
if (!validateForm()) {
console.log("Form chưa validate");
return; // Không đóng modal nếu validate fail
}
let messageToSend = "";
if (selectedSosMessage === 999) {
messageToSend = customMessage.trim();
@@ -79,17 +73,20 @@ const SosButton = () => {
);
messageToSend = selectedOption ? selectedOption.moTa : "";
}
// Gửi dữ liệu đi
await sendSosMessage(messageToSend);
// Đóng modal và reset form sau khi gửi thành công
setShowConfirmSosDialog(false);
// Reset form
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
await sendSosMessage(messageToSend);
}
};
const handleClickButton = async (isActive: boolean) => {
console.log("Is Active: ", isActive);
if (isActive) {
const resp = await queryDeleteSos();
if (resp.status === 200) {
@@ -115,66 +112,55 @@ const SosButton = () => {
return (
<>
<Button
className="shadow-md rounded-full"
size="lg"
action="negative"
<IconButton
icon={<MaterialIcons name="warning" size={20} color="white" />}
type="danger"
size="middle"
onPress={() => handleClickButton(sosData?.active || false)}
style={{ borderRadius: 20 }}
>
<MaterialIcons name="warning" size={15} color="white" />
<ButtonText className="text-center">
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
</ButtonText>
{/* <ButtonSpinner /> */}
{/* <ButtonIcon /> */}
</Button>
</IconButton>
<Modal
isOpen={showConfirmSosDialog}
onClose={() => {
open={showConfirmSosDialog}
onCancel={() => {
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
}}
okText={t("home.sos.confirm")}
cancelText={t("home.sos.cancel")}
title={t("home.sos.title")}
centered
onOk={handleConfirmSos}
>
<ModalBackdrop />
<ModalContent>
<ModalHeader className="flex-col gap-0.5 items-center">
<Text
style={{ fontSize: 18, fontWeight: "bold", textAlign: "center" }}
>
{t("home.sos.title")}
</Text>
</ModalHeader>
<ModalBody className="mb-4">
<ScrollView style={{ maxHeight: 400 }}>
{/* Dropdown Nội dung SOS */}
{/* Select Nội dung SOS */}
<View style={styles.formGroup}>
<Text style={styles.label}>{t("home.sos.content")}</Text>
<TouchableOpacity
style={[
styles.dropdownButton,
errors.sosMessage ? styles.errorBorder : {},
]}
onPress={() => setShowDropdown(!showDropdown)}
>
<Text
style={[
styles.dropdownButtonText,
!selectedSosMessage && styles.placeholderText,
]}
>
{selectedSosMessage !== null
? sosOptions.find((opt) => opt.ma === selectedSosMessage)
?.moTa || t("home.sos.selectReason")
: t("home.sos.selectReason")}
</Text>
<MaterialIcons
name={showDropdown ? "expand-less" : "expand-more"}
size={20}
color="#666"
<Select
value={selectedSosMessage ?? undefined}
options={sosOptions}
placeholder={t("home.sos.selectReason")}
onChange={(value) => {
setSelectedSosMessage(value as number);
// Clear custom message nếu chọn khác lý do
if (value !== 999) {
setCustomMessage("");
}
// Clear error if exists
if (errors.sosMessage) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors.sosMessage;
return newErrors;
});
}
}}
showSearch={false}
style={[errors.sosMessage ? styles.errorBorder : undefined]}
/>
</TouchableOpacity>
{errors.sosMessage && (
<Text style={styles.errorText}>{errors.sosMessage}</Text>
)}
@@ -210,72 +196,7 @@ const SosButton = () => {
)}
</View>
)}
</ScrollView>
</ModalBody>
<ModalFooter className="flex-row items-start gap-2">
<Button
onPress={handleConfirmSos}
// className="w-1/3"
action="negative"
>
<ButtonText>{t("home.sos.confirm")}</ButtonText>
</Button>
<Button
onPress={() => {
setShowConfirmSosDialog(false);
setSelectedSosMessage(null);
setCustomMessage("");
setErrors({});
}}
// className="w-1/3"
action="secondary"
>
<ButtonText>{t("home.sos.cancel")}</ButtonText>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* Dropdown Modal - Nổi lên */}
{showDropdown && showConfirmSosDialog && (
<Modal isOpen={showDropdown} onClose={() => setShowDropdown(false)}>
<TouchableOpacity
style={styles.dropdownOverlay}
activeOpacity={1}
onPress={() => setShowDropdown(false)}
>
<View style={styles.dropdownModalContainer}>
<FlatList
data={sosOptions}
keyExtractor={(item) => item.ma.toString()}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.dropdownModalItem}
onPress={() => {
setSelectedSosMessage(item.ma);
setShowDropdown(false);
// Clear custom message nếu chọn khác lý do
if (item.ma !== 999) {
setCustomMessage("");
}
}}
>
<Text
style={[
styles.dropdownModalItemText,
selectedSosMessage === item.ma &&
styles.selectedItemText,
]}
>
{item.moTa}
</Text>
</TouchableOpacity>
)}
/>
</View>
</TouchableOpacity>
</Modal>
)}
</>
);
};
@@ -290,76 +211,9 @@ const styles = StyleSheet.create({
marginBottom: 8,
color: "#333",
},
dropdownButton: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: "#fff",
},
errorBorder: {
borderColor: "#ff4444",
},
dropdownButtonText: {
fontSize: 14,
color: "#333",
flex: 1,
},
placeholderText: {
color: "#999",
},
dropdownList: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 8,
marginTop: 4,
backgroundColor: "#fff",
overflow: "hidden",
},
dropdownItem: {
paddingHorizontal: 12,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: "#eee",
},
dropdownItemText: {
fontSize: 14,
color: "#333",
},
dropdownOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
dropdownModalContainer: {
backgroundColor: "#fff",
borderRadius: 12,
maxHeight: 400,
minWidth: 280,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 10,
},
dropdownModalItem: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: "#f0f0f0",
},
dropdownModalItemText: {
fontSize: 14,
color: "#333",
},
selectedItemText: {
fontWeight: "600",
color: "#1054C9",
},
input: {
borderWidth: 1,
borderColor: "#ddd",

View File

@@ -1,296 +0,0 @@
'use client';
import React from 'react';
import { createAlertDialog } from '@gluestack-ui/core/alert-dialog/creator';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import {
withStyleContext,
useStyleContext,
} from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import {
Motion,
AnimatePresence,
createMotionAnimatedComponent,
MotionComponentProps,
} from '@legendapp/motion';
import { View, Pressable, ScrollView, ViewStyle } from 'react-native';
const SCOPE = 'ALERT_DIALOG';
const RootComponent = withStyleContext(View, SCOPE);
type IMotionViewProps = React.ComponentProps<typeof View> &
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
const AnimatedPressable = createMotionAnimatedComponent(
Pressable
) as React.ComponentType<IAnimatedPressableProps>;
const UIAccessibleAlertDialog = createAlertDialog({
Root: RootComponent,
Body: ScrollView,
Content: MotionView,
CloseButton: Pressable,
Header: View,
Footer: View,
Backdrop: AnimatedPressable,
AnimatePresence: AnimatePresence,
});
cssInterop(MotionView, { className: 'style' });
cssInterop(AnimatedPressable, { className: 'style' });
const alertDialogStyle = tva({
base: 'group/modal w-full h-full justify-center items-center web:pointer-events-none',
parentVariants: {
size: {
xs: '',
sm: '',
md: '',
lg: '',
full: '',
},
},
});
const alertDialogContentStyle = tva({
base: 'bg-background-0 rounded-lg overflow-hidden border border-outline-100 p-6',
parentVariants: {
size: {
xs: 'w-[60%] max-w-[360px]',
sm: 'w-[70%] max-w-[420px]',
md: 'w-[80%] max-w-[510px]',
lg: 'w-[90%] max-w-[640px]',
full: 'w-full',
},
},
});
const alertDialogCloseButtonStyle = tva({
base: 'group/alert-dialog-close-button z-10 rounded-sm p-2 data-[focus-visible=true]:bg-background-100 web:cursor-pointer outline-0',
});
const alertDialogHeaderStyle = tva({
base: 'justify-between items-center flex-row',
});
const alertDialogFooterStyle = tva({
base: 'flex-row justify-end items-center gap-3',
});
const alertDialogBodyStyle = tva({ base: '' });
const alertDialogBackdropStyle = tva({
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default',
});
type IAlertDialogProps = React.ComponentPropsWithoutRef<
typeof UIAccessibleAlertDialog
> &
VariantProps<typeof alertDialogStyle>;
type IAlertDialogContentProps = React.ComponentPropsWithoutRef<
typeof UIAccessibleAlertDialog.Content
> &
VariantProps<typeof alertDialogContentStyle> & { className?: string };
type IAlertDialogCloseButtonProps = React.ComponentPropsWithoutRef<
typeof UIAccessibleAlertDialog.CloseButton
> &
VariantProps<typeof alertDialogCloseButtonStyle>;
type IAlertDialogHeaderProps = React.ComponentPropsWithoutRef<
typeof UIAccessibleAlertDialog.Header
> &
VariantProps<typeof alertDialogHeaderStyle>;
type IAlertDialogFooterProps = React.ComponentPropsWithoutRef<
typeof UIAccessibleAlertDialog.Footer
> &
VariantProps<typeof alertDialogFooterStyle>;
type IAlertDialogBodyProps = React.ComponentPropsWithoutRef<
typeof UIAccessibleAlertDialog.Body
> &
VariantProps<typeof alertDialogBodyStyle>;
type IAlertDialogBackdropProps = React.ComponentPropsWithoutRef<
typeof UIAccessibleAlertDialog.Backdrop
> &
VariantProps<typeof alertDialogBackdropStyle> & { className?: string };
const AlertDialog = React.forwardRef<
React.ComponentRef<typeof UIAccessibleAlertDialog>,
IAlertDialogProps
>(function AlertDialog({ className, size = 'md', ...props }, ref) {
return (
<UIAccessibleAlertDialog
ref={ref}
{...props}
className={alertDialogStyle({ class: className })}
context={{ size }}
pointerEvents="box-none"
/>
);
});
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof UIAccessibleAlertDialog.Content>,
IAlertDialogContentProps
>(function AlertDialogContent({ className, size, ...props }, ref) {
const { size: parentSize } = useStyleContext(SCOPE);
return (
<UIAccessibleAlertDialog.Content
pointerEvents="auto"
ref={ref}
initial={{
scale: 0.9,
opacity: 0,
}}
animate={{
scale: 1,
opacity: 1,
}}
exit={{
scale: 0.9,
opacity: 0,
}}
transition={{
type: 'spring',
damping: 18,
stiffness: 250,
opacity: {
type: 'timing',
duration: 250,
},
}}
{...props}
className={alertDialogContentStyle({
parentVariants: {
size: parentSize,
},
size,
class: className,
})}
/>
);
});
const AlertDialogCloseButton = React.forwardRef<
React.ComponentRef<typeof UIAccessibleAlertDialog.CloseButton>,
IAlertDialogCloseButtonProps
>(function AlertDialogCloseButton({ className, ...props }, ref) {
return (
<UIAccessibleAlertDialog.CloseButton
ref={ref}
{...props}
className={alertDialogCloseButtonStyle({
class: className,
})}
/>
);
});
const AlertDialogHeader = React.forwardRef<
React.ComponentRef<typeof UIAccessibleAlertDialog.Header>,
IAlertDialogHeaderProps
>(function AlertDialogHeader({ className, ...props }, ref) {
return (
<UIAccessibleAlertDialog.Header
ref={ref}
{...props}
className={alertDialogHeaderStyle({
class: className,
})}
/>
);
});
const AlertDialogFooter = React.forwardRef<
React.ComponentRef<typeof UIAccessibleAlertDialog.Footer>,
IAlertDialogFooterProps
>(function AlertDialogFooter({ className, ...props }, ref) {
return (
<UIAccessibleAlertDialog.Footer
ref={ref}
{...props}
className={alertDialogFooterStyle({
class: className,
})}
/>
);
});
const AlertDialogBody = React.forwardRef<
React.ComponentRef<typeof UIAccessibleAlertDialog.Body>,
IAlertDialogBodyProps
>(function AlertDialogBody({ className, ...props }, ref) {
return (
<UIAccessibleAlertDialog.Body
ref={ref}
{...props}
className={alertDialogBodyStyle({
class: className,
})}
/>
);
});
const AlertDialogBackdrop = React.forwardRef<
React.ComponentRef<typeof UIAccessibleAlertDialog.Backdrop>,
IAlertDialogBackdropProps
>(function AlertDialogBackdrop({ className, ...props }, ref) {
return (
<UIAccessibleAlertDialog.Backdrop
ref={ref}
initial={{
opacity: 0,
}}
animate={{
opacity: 0.5,
}}
exit={{
opacity: 0,
}}
transition={{
type: 'spring',
damping: 18,
stiffness: 250,
opacity: {
type: 'timing',
duration: 250,
},
}}
{...props}
className={alertDialogBackdropStyle({
class: className,
})}
/>
);
});
AlertDialog.displayName = 'AlertDialog';
AlertDialogContent.displayName = 'AlertDialogContent';
AlertDialogCloseButton.displayName = 'AlertDialogCloseButton';
AlertDialogHeader.displayName = 'AlertDialogHeader';
AlertDialogFooter.displayName = 'AlertDialogFooter';
AlertDialogBody.displayName = 'AlertDialogBody';
AlertDialogBackdrop.displayName = 'AlertDialogBackdrop';
export {
AlertDialog,
AlertDialogContent,
AlertDialogCloseButton,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogBody,
AlertDialogBackdrop,
};

View File

@@ -1,434 +0,0 @@
'use client';
import React from 'react';
import { createButton } from '@gluestack-ui/core/button/creator';
import {
tva,
withStyleContext,
useStyleContext,
type VariantProps,
} from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import { ActivityIndicator, Pressable, Text, View } from 'react-native';
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
const SCOPE = 'BUTTON';
const Root = withStyleContext(Pressable, SCOPE);
const UIButton = createButton({
Root: Root,
Text,
Group: View,
Spinner: ActivityIndicator,
Icon: UIIcon,
});
cssInterop(PrimitiveIcon, {
className: {
target: 'style',
nativeStyleToProp: {
height: true,
width: true,
fill: true,
color: 'classNameColor',
stroke: true,
},
},
});
const buttonStyle = tva({
base: 'group/button rounded bg-primary-500 flex-row items-center justify-center data-[focus-visible=true]:web:outline-none data-[focus-visible=true]:web:ring-2 data-[disabled=true]:opacity-40 gap-2',
variants: {
action: {
primary:
'bg-primary-500 data-[hover=true]:bg-primary-600 data-[active=true]:bg-primary-700 border-primary-300 data-[hover=true]:border-primary-400 data-[active=true]:border-primary-500 data-[focus-visible=true]:web:ring-indicator-info',
secondary:
'bg-secondary-500 border-secondary-300 data-[hover=true]:bg-secondary-600 data-[hover=true]:border-secondary-400 data-[active=true]:bg-secondary-700 data-[active=true]:border-secondary-700 data-[focus-visible=true]:web:ring-indicator-info',
positive:
'bg-success-500 border-success-300 data-[hover=true]:bg-success-600 data-[hover=true]:border-success-400 data-[active=true]:bg-success-700 data-[active=true]:border-success-500 data-[focus-visible=true]:web:ring-indicator-info',
negative:
'bg-error-500 border-error-300 data-[hover=true]:bg-error-600 data-[hover=true]:border-error-400 data-[active=true]:bg-error-700 data-[active=true]:border-error-500 data-[focus-visible=true]:web:ring-indicator-info',
default:
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
},
variant: {
link: 'px-0',
outline:
'bg-transparent border data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
solid: '',
},
size: {
xs: 'px-3.5 h-8',
sm: 'px-4 h-9',
md: 'px-5 h-10',
lg: 'px-6 h-11',
xl: 'px-7 h-12',
},
},
compoundVariants: [
{
action: 'primary',
variant: 'link',
class:
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
},
{
action: 'secondary',
variant: 'link',
class:
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
},
{
action: 'positive',
variant: 'link',
class:
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
},
{
action: 'negative',
variant: 'link',
class:
'px-0 bg-transparent data-[hover=true]:bg-transparent data-[active=true]:bg-transparent',
},
{
action: 'primary',
variant: 'outline',
class:
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
},
{
action: 'secondary',
variant: 'outline',
class:
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
},
{
action: 'positive',
variant: 'outline',
class:
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
},
{
action: 'negative',
variant: 'outline',
class:
'bg-transparent data-[hover=true]:bg-background-50 data-[active=true]:bg-transparent',
},
],
});
const buttonTextStyle = tva({
base: 'text-typography-0 font-semibold web:select-none',
parentVariants: {
action: {
primary:
'text-primary-600 data-[hover=true]:text-primary-600 data-[active=true]:text-primary-700',
secondary:
'text-typography-500 data-[hover=true]:text-typography-600 data-[active=true]:text-typography-700',
positive:
'text-success-600 data-[hover=true]:text-success-600 data-[active=true]:text-success-700',
negative:
'text-error-600 data-[hover=true]:text-error-600 data-[active=true]:text-error-700',
},
variant: {
link: 'data-[hover=true]:underline data-[active=true]:underline',
outline: '',
solid:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
size: {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
},
},
parentCompoundVariants: [
{
variant: 'solid',
action: 'primary',
class:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
{
variant: 'solid',
action: 'secondary',
class:
'text-typography-800 data-[hover=true]:text-typography-800 data-[active=true]:text-typography-800',
},
{
variant: 'solid',
action: 'positive',
class:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
{
variant: 'solid',
action: 'negative',
class:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
{
variant: 'outline',
action: 'primary',
class:
'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500',
},
{
variant: 'outline',
action: 'secondary',
class:
'text-typography-500 data-[hover=true]:text-primary-600 data-[active=true]:text-typography-700',
},
{
variant: 'outline',
action: 'positive',
class:
'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500',
},
{
variant: 'outline',
action: 'negative',
class:
'text-primary-500 data-[hover=true]:text-primary-500 data-[active=true]:text-primary-500',
},
],
});
const buttonIconStyle = tva({
base: 'fill-none',
parentVariants: {
variant: {
link: 'data-[hover=true]:underline data-[active=true]:underline',
outline: '',
solid:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
size: {
xs: 'h-3.5 w-3.5',
sm: 'h-4 w-4',
md: 'h-[18px] w-[18px]',
lg: 'h-[18px] w-[18px]',
xl: 'h-5 w-5',
},
action: {
primary:
'text-primary-600 data-[hover=true]:text-primary-600 data-[active=true]:text-primary-700',
secondary:
'text-typography-500 data-[hover=true]:text-typography-600 data-[active=true]:text-typography-700',
positive:
'text-success-600 data-[hover=true]:text-success-600 data-[active=true]:text-success-700',
negative:
'text-error-600 data-[hover=true]:text-error-600 data-[active=true]:text-error-700',
},
},
parentCompoundVariants: [
{
variant: 'solid',
action: 'primary',
class:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
{
variant: 'solid',
action: 'secondary',
class:
'text-typography-800 data-[hover=true]:text-typography-800 data-[active=true]:text-typography-800',
},
{
variant: 'solid',
action: 'positive',
class:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
{
variant: 'solid',
action: 'negative',
class:
'text-typography-0 data-[hover=true]:text-typography-0 data-[active=true]:text-typography-0',
},
],
});
const buttonGroupStyle = tva({
base: '',
variants: {
space: {
'xs': 'gap-1',
'sm': 'gap-2',
'md': 'gap-3',
'lg': 'gap-4',
'xl': 'gap-5',
'2xl': 'gap-6',
'3xl': 'gap-7',
'4xl': 'gap-8',
},
isAttached: {
true: 'gap-0',
},
flexDirection: {
'row': 'flex-row',
'column': 'flex-col',
'row-reverse': 'flex-row-reverse',
'column-reverse': 'flex-col-reverse',
},
},
});
type IButtonProps = Omit<
React.ComponentPropsWithoutRef<typeof UIButton>,
'context'
> &
VariantProps<typeof buttonStyle> & { className?: string };
const Button = React.forwardRef<
React.ElementRef<typeof UIButton>,
IButtonProps
>(
(
{ className, variant = 'solid', size = 'md', action = 'primary', ...props },
ref
) => {
return (
<UIButton
ref={ref}
{...props}
className={buttonStyle({ variant, size, action, class: className })}
context={{ variant, size, action }}
/>
);
}
);
type IButtonTextProps = React.ComponentPropsWithoutRef<typeof UIButton.Text> &
VariantProps<typeof buttonTextStyle> & { className?: string };
const ButtonText = React.forwardRef<
React.ElementRef<typeof UIButton.Text>,
IButtonTextProps
>(({ className, variant, size, action, ...props }, ref) => {
const {
variant: parentVariant,
size: parentSize,
action: parentAction,
} = useStyleContext(SCOPE);
return (
<UIButton.Text
ref={ref}
{...props}
className={buttonTextStyle({
parentVariants: {
variant: parentVariant,
size: parentSize,
action: parentAction,
},
variant,
size,
action,
class: className,
})}
/>
);
});
const ButtonSpinner = UIButton.Spinner;
type IButtonIcon = React.ComponentPropsWithoutRef<typeof UIButton.Icon> &
VariantProps<typeof buttonIconStyle> & {
className?: string | undefined;
as?: React.ElementType;
height?: number;
width?: number;
};
const ButtonIcon = React.forwardRef<
React.ElementRef<typeof UIButton.Icon>,
IButtonIcon
>(({ className, size, ...props }, ref) => {
const {
variant: parentVariant,
size: parentSize,
action: parentAction,
} = useStyleContext(SCOPE);
if (typeof size === 'number') {
return (
<UIButton.Icon
ref={ref}
{...props}
className={buttonIconStyle({ class: className })}
size={size}
/>
);
} else if (
(props.height !== undefined || props.width !== undefined) &&
size === undefined
) {
return (
<UIButton.Icon
ref={ref}
{...props}
className={buttonIconStyle({ class: className })}
/>
);
}
return (
<UIButton.Icon
{...props}
className={buttonIconStyle({
parentVariants: {
size: parentSize,
variant: parentVariant,
action: parentAction,
},
size,
class: className,
})}
ref={ref}
/>
);
});
type IButtonGroupProps = React.ComponentPropsWithoutRef<typeof UIButton.Group> &
VariantProps<typeof buttonGroupStyle>;
const ButtonGroup = React.forwardRef<
React.ElementRef<typeof UIButton.Group>,
IButtonGroupProps
>(
(
{
className,
space = 'md',
isAttached = false,
flexDirection = 'column',
...props
},
ref
) => {
return (
<UIButton.Group
className={buttonGroupStyle({
class: className,
space,
isAttached,
flexDirection,
})}
{...props}
ref={ref}
/>
);
}
);
Button.displayName = 'Button';
ButtonText.displayName = 'ButtonText';
ButtonSpinner.displayName = 'ButtonSpinner';
ButtonIcon.displayName = 'ButtonIcon';
ButtonGroup.displayName = 'ButtonGroup';
export { Button, ButtonText, ButtonSpinner, ButtonIcon, ButtonGroup };

View File

@@ -1,309 +0,0 @@
'use client';
import { vars } from 'nativewind';
export const config = {
light: vars({
'--color-primary-0': '179 179 179',
'--color-primary-50': '153 153 153',
'--color-primary-100': '128 128 128',
'--color-primary-200': '115 115 115',
'--color-primary-300': '102 102 102',
'--color-primary-400': '82 82 82',
'--color-primary-500': '51 51 51',
'--color-primary-600': '41 41 41',
'--color-primary-700': '31 31 31',
'--color-primary-800': '13 13 13',
'--color-primary-900': '10 10 10',
'--color-primary-950': '8 8 8',
/* Secondary */
'--color-secondary-0': '253 253 253',
'--color-secondary-50': '251 251 251',
'--color-secondary-100': '246 246 246',
'--color-secondary-200': '242 242 242',
'--color-secondary-300': '237 237 237',
'--color-secondary-400': '230 230 231',
'--color-secondary-500': '217 217 219',
'--color-secondary-600': '198 199 199',
'--color-secondary-700': '189 189 189',
'--color-secondary-800': '177 177 177',
'--color-secondary-900': '165 164 164',
'--color-secondary-950': '157 157 157',
/* Tertiary */
'--color-tertiary-0': '255 250 245',
'--color-tertiary-50': '255 242 229',
'--color-tertiary-100': '255 233 213',
'--color-tertiary-200': '254 209 170',
'--color-tertiary-300': '253 180 116',
'--color-tertiary-400': '251 157 75',
'--color-tertiary-500': '231 129 40',
'--color-tertiary-600': '215 117 31',
'--color-tertiary-700': '180 98 26',
'--color-tertiary-800': '130 73 23',
'--color-tertiary-900': '108 61 19',
'--color-tertiary-950': '84 49 18',
/* Error */
'--color-error-0': '254 233 233',
'--color-error-50': '254 226 226',
'--color-error-100': '254 202 202',
'--color-error-200': '252 165 165',
'--color-error-300': '248 113 113',
'--color-error-400': '239 68 68',
'--color-error-500': '230 53 53',
'--color-error-600': '220 38 38',
'--color-error-700': '185 28 28',
'--color-error-800': '153 27 27',
'--color-error-900': '127 29 29',
'--color-error-950': '83 19 19',
/* Success */
'--color-success-0': '228 255 244',
'--color-success-50': '202 255 232',
'--color-success-100': '162 241 192',
'--color-success-200': '132 211 162',
'--color-success-300': '102 181 132',
'--color-success-400': '72 151 102',
'--color-success-500': '52 131 82',
'--color-success-600': '42 121 72',
'--color-success-700': '32 111 62',
'--color-success-800': '22 101 52',
'--color-success-900': '20 83 45',
'--color-success-950': '27 50 36',
/* Warning */
'--color-warning-0': '255 249 245',
'--color-warning-50': '255 244 236',
'--color-warning-100': '255 231 213',
'--color-warning-200': '254 205 170',
'--color-warning-300': '253 173 116',
'--color-warning-400': '251 149 75',
'--color-warning-500': '231 120 40',
'--color-warning-600': '215 108 31',
'--color-warning-700': '180 90 26',
'--color-warning-800': '130 68 23',
'--color-warning-900': '108 56 19',
'--color-warning-950': '84 45 18',
/* Info */
'--color-info-0': '236 248 254',
'--color-info-50': '199 235 252',
'--color-info-100': '162 221 250',
'--color-info-200': '124 207 248',
'--color-info-300': '87 194 246',
'--color-info-400': '50 180 244',
'--color-info-500': '13 166 242',
'--color-info-600': '11 141 205',
'--color-info-700': '9 115 168',
'--color-info-800': '7 90 131',
'--color-info-900': '5 64 93',
'--color-info-950': '3 38 56',
/* Typography */
'--color-typography-0': '254 254 255',
'--color-typography-50': '245 245 245',
'--color-typography-100': '229 229 229',
'--color-typography-200': '219 219 220',
'--color-typography-300': '212 212 212',
'--color-typography-400': '163 163 163',
'--color-typography-500': '140 140 140',
'--color-typography-600': '115 115 115',
'--color-typography-700': '82 82 82',
'--color-typography-800': '64 64 64',
'--color-typography-900': '38 38 39',
'--color-typography-950': '23 23 23',
/* Outline */
'--color-outline-0': '253 254 254',
'--color-outline-50': '243 243 243',
'--color-outline-100': '230 230 230',
'--color-outline-200': '221 220 219',
'--color-outline-300': '211 211 211',
'--color-outline-400': '165 163 163',
'--color-outline-500': '140 141 141',
'--color-outline-600': '115 116 116',
'--color-outline-700': '83 82 82',
'--color-outline-800': '65 65 65',
'--color-outline-900': '39 38 36',
'--color-outline-950': '26 23 23',
/* Background */
'--color-background-0': '255 255 255',
'--color-background-50': '246 246 246',
'--color-background-100': '242 241 241',
'--color-background-200': '220 219 219',
'--color-background-300': '213 212 212',
'--color-background-400': '162 163 163',
'--color-background-500': '142 142 142',
'--color-background-600': '116 116 116',
'--color-background-700': '83 82 82',
'--color-background-800': '65 64 64',
'--color-background-900': '39 38 37',
'--color-background-950': '18 18 18',
/* Background Special */
'--color-background-error': '254 241 241',
'--color-background-warning': '255 243 234',
'--color-background-success': '237 252 242',
'--color-background-muted': '247 248 247',
'--color-background-info': '235 248 254',
/* Focus Ring Indicator */
'--color-indicator-primary': '55 55 55',
'--color-indicator-info': '83 153 236',
'--color-indicator-error': '185 28 28',
}),
dark: vars({
'--color-primary-0': '166 166 166',
'--color-primary-50': '175 175 175',
'--color-primary-100': '186 186 186',
'--color-primary-200': '197 197 197',
'--color-primary-300': '212 212 212',
'--color-primary-400': '221 221 221',
'--color-primary-500': '230 230 230',
'--color-primary-600': '240 240 240',
'--color-primary-700': '250 250 250',
'--color-primary-800': '253 253 253',
'--color-primary-900': '254 249 249',
'--color-primary-950': '253 252 252',
/* Secondary */
'--color-secondary-0': '20 20 20',
'--color-secondary-50': '23 23 23',
'--color-secondary-100': '31 31 31',
'--color-secondary-200': '39 39 39',
'--color-secondary-300': '44 44 44',
'--color-secondary-400': '56 57 57',
'--color-secondary-500': '63 64 64',
'--color-secondary-600': '86 86 86',
'--color-secondary-700': '110 110 110',
'--color-secondary-800': '135 135 135',
'--color-secondary-900': '150 150 150',
'--color-secondary-950': '164 164 164',
/* Tertiary */
'--color-tertiary-0': '84 49 18',
'--color-tertiary-50': '108 61 19',
'--color-tertiary-100': '130 73 23',
'--color-tertiary-200': '180 98 26',
'--color-tertiary-300': '215 117 31',
'--color-tertiary-400': '231 129 40',
'--color-tertiary-500': '251 157 75',
'--color-tertiary-600': '253 180 116',
'--color-tertiary-700': '254 209 170',
'--color-tertiary-800': '255 233 213',
'--color-tertiary-900': '255 242 229',
'--color-tertiary-950': '255 250 245',
/* Error */
'--color-error-0': '83 19 19',
'--color-error-50': '127 29 29',
'--color-error-100': '153 27 27',
'--color-error-200': '185 28 28',
'--color-error-300': '220 38 38',
'--color-error-400': '230 53 53',
'--color-error-500': '239 68 68',
'--color-error-600': '249 97 96',
'--color-error-700': '229 91 90',
'--color-error-800': '254 202 202',
'--color-error-900': '254 226 226',
'--color-error-950': '254 233 233',
/* Success */
'--color-success-0': '27 50 36',
'--color-success-50': '20 83 45',
'--color-success-100': '22 101 52',
'--color-success-200': '32 111 62',
'--color-success-300': '42 121 72',
'--color-success-400': '52 131 82',
'--color-success-500': '72 151 102',
'--color-success-600': '102 181 132',
'--color-success-700': '132 211 162',
'--color-success-800': '162 241 192',
'--color-success-900': '202 255 232',
'--color-success-950': '228 255 244',
/* Warning */
'--color-warning-0': '84 45 18',
'--color-warning-50': '108 56 19',
'--color-warning-100': '130 68 23',
'--color-warning-200': '180 90 26',
'--color-warning-300': '215 108 31',
'--color-warning-400': '231 120 40',
'--color-warning-500': '251 149 75',
'--color-warning-600': '253 173 116',
'--color-warning-700': '254 205 170',
'--color-warning-800': '255 231 213',
'--color-warning-900': '255 244 237',
'--color-warning-950': '255 249 245',
/* Info */
'--color-info-0': '3 38 56',
'--color-info-50': '5 64 93',
'--color-info-100': '7 90 131',
'--color-info-200': '9 115 168',
'--color-info-300': '11 141 205',
'--color-info-400': '13 166 242',
'--color-info-500': '50 180 244',
'--color-info-600': '87 194 246',
'--color-info-700': '124 207 248',
'--color-info-800': '162 221 250',
'--color-info-900': '199 235 252',
'--color-info-950': '236 248 254',
/* Typography */
'--color-typography-0': '23 23 23',
'--color-typography-50': '38 38 39',
'--color-typography-100': '64 64 64',
'--color-typography-200': '82 82 82',
'--color-typography-300': '115 115 115',
'--color-typography-400': '140 140 140',
'--color-typography-500': '163 163 163',
'--color-typography-600': '212 212 212',
'--color-typography-700': '219 219 220',
'--color-typography-800': '229 229 229',
'--color-typography-900': '245 245 245',
'--color-typography-950': '254 254 255',
/* Outline */
'--color-outline-0': '26 23 23',
'--color-outline-50': '39 38 36',
'--color-outline-100': '65 65 65',
'--color-outline-200': '83 82 82',
'--color-outline-300': '115 116 116',
'--color-outline-400': '140 141 141',
'--color-outline-500': '165 163 163',
'--color-outline-600': '211 211 211',
'--color-outline-700': '221 220 219',
'--color-outline-800': '230 230 230',
'--color-outline-900': '243 243 243',
'--color-outline-950': '253 254 254',
/* Background */
'--color-background-0': '18 18 18',
'--color-background-50': '39 38 37',
'--color-background-100': '65 64 64',
'--color-background-200': '83 82 82',
'--color-background-300': '116 116 116',
'--color-background-400': '142 142 142',
'--color-background-500': '162 163 163',
'--color-background-600': '213 212 212',
'--color-background-700': '229 228 228',
'--color-background-800': '242 241 241',
'--color-background-900': '246 246 246',
'--color-background-950': '255 255 255',
/* Background Special */
'--color-background-error': '66 43 43',
'--color-background-warning': '65 47 35',
'--color-background-success': '28 43 33',
'--color-background-muted': '51 51 51',
'--color-background-info': '26 40 46',
/* Focus Ring Indicator */
'--color-indicator-primary': '247 247 247',
'--color-indicator-info': '161 199 245',
'--color-indicator-error': '232 70 69',
}),
};

View File

@@ -1,87 +0,0 @@
// This is a Next.js 15 compatible version of the GluestackUIProvider
'use client';
import React, { useEffect, useLayoutEffect } from 'react';
import { config } from './config';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils';
import { script } from './script';
const variableStyleTagId = 'nativewind-style';
const createStyle = (styleTagId: string) => {
const style = document.createElement('style');
style.id = styleTagId;
style.appendChild(document.createTextNode(''));
return style;
};
export const useSafeLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export function GluestackUIProvider({
mode = 'light',
...props
}: {
mode?: 'light' | 'dark' | 'system';
children?: React.ReactNode;
}) {
let cssVariablesWithMode = ``;
Object.keys(config).forEach((configKey) => {
cssVariablesWithMode +=
configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
const cssVariables = Object.keys(
config[configKey as keyof typeof config]
).reduce((acc: string, curr: string) => {
acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
return acc;
}, '');
cssVariablesWithMode += `${cssVariables} \n}`;
});
setFlushStyles(cssVariablesWithMode);
const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => {
script(e.matches ? 'dark' : 'light');
}, []);
useSafeLayoutEffect(() => {
if (mode !== 'system') {
const documentElement = document.documentElement;
if (documentElement) {
documentElement.classList.add(mode);
documentElement.classList.remove(mode === 'light' ? 'dark' : 'light');
documentElement.style.colorScheme = mode;
}
}
}, [mode]);
useSafeLayoutEffect(() => {
if (mode !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
media.addListener(handleMediaQuery);
return () => media.removeListener(handleMediaQuery);
}, [handleMediaQuery]);
useSafeLayoutEffect(() => {
if (typeof window !== 'undefined') {
const documentElement = document.documentElement;
if (documentElement) {
const head = documentElement.querySelector('head');
let style = head?.querySelector(`[id='${variableStyleTagId}']`);
if (!style) {
style = createStyle(variableStyleTagId);
style.innerHTML = cssVariablesWithMode;
if (head) head.appendChild(style);
}
}
}
}, []);
return (
<OverlayProvider>
<ToastProvider>{props.children}</ToastProvider>
</OverlayProvider>
);
}

View File

@@ -1,38 +0,0 @@
import React, { useEffect } from 'react';
import { config } from './config';
import { View, ViewProps } from 'react-native';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
import { useColorScheme } from 'nativewind';
export type ModeType = 'light' | 'dark' | 'system';
export function GluestackUIProvider({
mode = 'light',
...props
}: {
mode?: ModeType;
children?: React.ReactNode;
style?: ViewProps['style'];
}) {
const { colorScheme, setColorScheme } = useColorScheme();
useEffect(() => {
setColorScheme(mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
return (
<View
style={[
config[colorScheme!],
{ flex: 1, height: '100%', width: '100%' },
props.style,
]}
>
<OverlayProvider>
<ToastProvider>{props.children}</ToastProvider>
</OverlayProvider>
</View>
);
}

View File

@@ -1,96 +0,0 @@
'use client';
import React, { useEffect, useLayoutEffect } from 'react';
import { config } from './config';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
import { setFlushStyles } from '@gluestack-ui/utils/nativewind-utils';
import { script } from './script';
export type ModeType = 'light' | 'dark' | 'system';
const variableStyleTagId = 'nativewind-style';
const createStyle = (styleTagId: string) => {
const style = document.createElement('style');
style.id = styleTagId;
style.appendChild(document.createTextNode(''));
return style;
};
export const useSafeLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export function GluestackUIProvider({
mode = 'light',
...props
}: {
mode?: ModeType;
children?: React.ReactNode;
}) {
let cssVariablesWithMode = ``;
Object.keys(config).forEach((configKey) => {
cssVariablesWithMode +=
configKey === 'dark' ? `\n .dark {\n ` : `\n:root {\n`;
const cssVariables = Object.keys(
config[configKey as keyof typeof config]
).reduce((acc: string, curr: string) => {
acc += `${curr}:${config[configKey as keyof typeof config][curr]}; `;
return acc;
}, '');
cssVariablesWithMode += `${cssVariables} \n}`;
});
setFlushStyles(cssVariablesWithMode);
const handleMediaQuery = React.useCallback((e: MediaQueryListEvent) => {
script(e.matches ? 'dark' : 'light');
}, []);
useSafeLayoutEffect(() => {
if (mode !== 'system') {
const documentElement = document.documentElement;
if (documentElement) {
documentElement.classList.add(mode);
documentElement.classList.remove(mode === 'light' ? 'dark' : 'light');
documentElement.style.colorScheme = mode;
}
}
}, [mode]);
useSafeLayoutEffect(() => {
if (mode !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
media.addListener(handleMediaQuery);
return () => media.removeListener(handleMediaQuery);
}, [handleMediaQuery]);
useSafeLayoutEffect(() => {
if (typeof window !== 'undefined') {
const documentElement = document.documentElement;
if (documentElement) {
const head = documentElement.querySelector('head');
let style = head?.querySelector(`[id='${variableStyleTagId}']`);
if (!style) {
style = createStyle(variableStyleTagId);
style.innerHTML = cssVariablesWithMode;
if (head) head.appendChild(style);
}
}
}
}, []);
return (
<>
<script
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: `(${script.toString()})('${mode}')`,
}}
/>
<OverlayProvider>
<ToastProvider>{props.children}</ToastProvider>
</OverlayProvider>
</>
);
}

View File

@@ -1,19 +0,0 @@
export const script = (mode: string) => {
const documentElement = document.documentElement;
function getSystemColorMode() {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
try {
const isSystem = mode === 'system';
const theme = isSystem ? getSystemColorMode() : mode;
documentElement.classList.remove(theme === 'light' ? 'dark' : 'light');
documentElement.classList.add(theme);
documentElement.style.colorScheme = theme;
} catch (e) {
console.error(e);
}
};

View File

@@ -1,276 +0,0 @@
'use client';
import React from 'react';
import { createModal } from '@gluestack-ui/core/modal/creator';
import { Pressable, View, ScrollView, ViewStyle } from 'react-native';
import {
Motion,
AnimatePresence,
createMotionAnimatedComponent,
MotionComponentProps,
} from '@legendapp/motion';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import {
withStyleContext,
useStyleContext,
} from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
const AnimatedPressable = createMotionAnimatedComponent(
Pressable
) as React.ComponentType<IAnimatedPressableProps>;
const SCOPE = 'MODAL';
type IMotionViewProps = React.ComponentProps<typeof View> &
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
const UIModal = createModal({
Root: withStyleContext(View, SCOPE),
Backdrop: AnimatedPressable,
Content: MotionView,
Body: ScrollView,
CloseButton: Pressable,
Footer: View,
Header: View,
AnimatePresence: AnimatePresence,
});
cssInterop(AnimatedPressable, { className: 'style' });
cssInterop(MotionView, { className: 'style' });
const modalStyle = tva({
base: 'group/modal w-full h-full justify-center items-center web:pointer-events-none',
variants: {
size: {
xs: '',
sm: '',
md: '',
lg: '',
full: '',
},
},
});
const modalBackdropStyle = tva({
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default',
});
const modalContentStyle = tva({
base: 'bg-background-0 rounded-md overflow-hidden border border-outline-100 shadow-hard-2 p-6',
parentVariants: {
size: {
xs: 'w-[60%] max-w-[360px]',
sm: 'w-[70%] max-w-[420px]',
md: 'w-[80%] max-w-[510px]',
lg: 'w-[90%] max-w-[640px]',
full: 'w-full',
},
},
});
const modalBodyStyle = tva({
base: 'mt-2 mb-6',
});
const modalCloseButtonStyle = tva({
base: 'group/modal-close-button z-10 rounded data-[focus-visible=true]:web:bg-background-100 web:outline-0 cursor-pointer',
});
const modalHeaderStyle = tva({
base: 'justify-between items-center flex-row',
});
const modalFooterStyle = tva({
base: 'flex-row justify-end items-center gap-2',
});
type IModalProps = React.ComponentProps<typeof UIModal> &
VariantProps<typeof modalStyle> & { className?: string };
type IModalBackdropProps = React.ComponentProps<typeof UIModal.Backdrop> &
VariantProps<typeof modalBackdropStyle> & { className?: string };
type IModalContentProps = React.ComponentProps<typeof UIModal.Content> &
VariantProps<typeof modalContentStyle> & { className?: string };
type IModalHeaderProps = React.ComponentProps<typeof UIModal.Header> &
VariantProps<typeof modalHeaderStyle> & { className?: string };
type IModalBodyProps = React.ComponentProps<typeof UIModal.Body> &
VariantProps<typeof modalBodyStyle> & { className?: string };
type IModalFooterProps = React.ComponentProps<typeof UIModal.Footer> &
VariantProps<typeof modalFooterStyle> & { className?: string };
type IModalCloseButtonProps = React.ComponentProps<typeof UIModal.CloseButton> &
VariantProps<typeof modalCloseButtonStyle> & { className?: string };
const Modal = React.forwardRef<React.ComponentRef<typeof UIModal>, IModalProps>(
({ className, size = 'md', ...props }, ref) => (
<UIModal
ref={ref}
{...props}
pointerEvents="box-none"
className={modalStyle({ size, class: className })}
context={{ size }}
/>
)
);
const ModalBackdrop = React.forwardRef<
React.ComponentRef<typeof UIModal.Backdrop>,
IModalBackdropProps
>(function ModalBackdrop({ className, ...props }, ref) {
return (
<UIModal.Backdrop
ref={ref}
initial={{
opacity: 0,
}}
animate={{
opacity: 0.5,
}}
exit={{
opacity: 0,
}}
transition={{
type: 'spring',
damping: 18,
stiffness: 250,
opacity: {
type: 'timing',
duration: 250,
},
}}
{...props}
className={modalBackdropStyle({
class: className,
})}
/>
);
});
const ModalContent = React.forwardRef<
React.ComponentRef<typeof UIModal.Content>,
IModalContentProps
>(function ModalContent({ className, size, ...props }, ref) {
const { size: parentSize } = useStyleContext(SCOPE);
return (
<UIModal.Content
ref={ref}
initial={{
opacity: 0,
scale: 0.9,
}}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
}}
transition={{
type: 'spring',
damping: 18,
stiffness: 250,
opacity: {
type: 'timing',
duration: 250,
},
}}
{...props}
className={modalContentStyle({
parentVariants: {
size: parentSize,
},
size,
class: className,
})}
pointerEvents="auto"
/>
);
});
const ModalHeader = React.forwardRef<
React.ComponentRef<typeof UIModal.Header>,
IModalHeaderProps
>(function ModalHeader({ className, ...props }, ref) {
return (
<UIModal.Header
ref={ref}
{...props}
className={modalHeaderStyle({
class: className,
})}
/>
);
});
const ModalBody = React.forwardRef<
React.ComponentRef<typeof UIModal.Body>,
IModalBodyProps
>(function ModalBody({ className, ...props }, ref) {
return (
<UIModal.Body
ref={ref}
{...props}
className={modalBodyStyle({
class: className,
})}
/>
);
});
const ModalFooter = React.forwardRef<
React.ComponentRef<typeof UIModal.Footer>,
IModalFooterProps
>(function ModalFooter({ className, ...props }, ref) {
return (
<UIModal.Footer
ref={ref}
{...props}
className={modalFooterStyle({
class: className,
})}
/>
);
});
const ModalCloseButton = React.forwardRef<
React.ComponentRef<typeof UIModal.CloseButton>,
IModalCloseButtonProps
>(function ModalCloseButton({ className, ...props }, ref) {
return (
<UIModal.CloseButton
ref={ref}
{...props}
className={modalCloseButtonStyle({
class: className,
})}
/>
);
});
Modal.displayName = 'Modal';
ModalBackdrop.displayName = 'ModalBackdrop';
ModalContent.displayName = 'ModalContent';
ModalHeader.displayName = 'ModalHeader';
ModalBody.displayName = 'ModalBody';
ModalFooter.displayName = 'ModalFooter';
ModalCloseButton.displayName = 'ModalCloseButton';
export {
Modal,
ModalBackdrop,
ModalContent,
ModalCloseButton,
ModalHeader,
ModalBody,
ModalFooter,
};

View File

@@ -1,345 +0,0 @@
'use client';
import React from 'react';
import { View, Pressable, ScrollView, ViewStyle } from 'react-native';
import {
Motion,
createMotionAnimatedComponent,
AnimatePresence,
MotionComponentProps,
} from '@legendapp/motion';
import { createPopover } from '@gluestack-ui/core/popover/creator';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import {
withStyleContext,
useStyleContext,
} from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
const AnimatedPressable = createMotionAnimatedComponent(
Pressable
) as React.ComponentType<IAnimatedPressableProps>;
const SCOPE = 'POPOVER';
type IMotionViewProps = React.ComponentProps<typeof View> &
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
const UIPopover = createPopover({
Root: withStyleContext(View, SCOPE),
Arrow: MotionView,
Backdrop: AnimatedPressable,
Body: ScrollView,
CloseButton: Pressable,
Content: MotionView,
Footer: View,
Header: View,
AnimatePresence: AnimatePresence,
});
cssInterop(MotionView, { className: 'style' });
cssInterop(AnimatedPressable, { className: 'style' });
const popoverStyle = tva({
base: 'group/popover w-full h-full justify-center items-center web:pointer-events-none',
variants: {
size: {
xs: '',
sm: '',
md: '',
lg: '',
full: '',
},
},
});
const popoverArrowStyle = tva({
base: 'bg-background-0 z-[1] border absolute overflow-hidden h-3.5 w-3.5 border-outline-100',
variants: {
placement: {
'top left':
'data-[flip=false]:border-t-0 data-[flip=false]:border-l-0 data-[flip=true]:border-b-0 data-[flip=true]:border-r-0',
'top':
'data-[flip=false]:border-t-0 data-[flip=false]:border-l-0 data-[flip=true]:border-b-0 data-[flip=true]:border-r-0',
'top right':
'data-[flip=false]:border-t-0 data-[flip=false]:border-l-0 data-[flip=true]:border-b-0 data-[flip=true]:border-r-0',
'bottom':
'data-[flip=false]:border-b-0 data-[flip=false]:border-r-0 data-[flip=true]:border-t-0 data-[flip=true]:border-l-0',
'bottom left':
'data-[flip=false]:border-b-0 data-[flip=false]:border-r-0 data-[flip=true]:border-t-0 data-[flip=true]:border-l-0',
'bottom right':
'data-[flip=false]:border-b-0 data-[flip=false]:border-r-0 data-[flip=true]:border-t-0 data-[flip=true]:border-l-0',
'left':
'data-[flip=false]:border-l-0 data-[flip=false]:border-b-0 data-[flip=true]:border-r-0 data-[flip=true]:border-t-0',
'left top':
'data-[flip=false]:border-l-0 data-[flip=false]:border-b-0 data-[flip=true]:border-r-0 data-[flip=true]:border-t-0',
'left bottom':
'data-[flip=false]:border-l-0 data-[flip=false]:border-b-0 data-[flip=true]:border-r-0 data-[flip=true]:border-t-0',
'right':
'data-[flip=false]:border-r-0 data-[flip=false]:border-t-0 data-[flip=true]:border-l-0 data-[flip=true]:border-b-0',
'right top':
'data-[flip=false]:border-r-0 data-[flip=false]:border-t-0 data-[flip=true]:border-l-0 data-[flip=true]:border-b-0',
'right bottom':
'data-[flip=false]:border-r-0 data-[flip=false]:border-t-0 data-[flip=true]:border-l-0 data-[flip=true]:border-b-0',
},
},
});
const popoverBackdropStyle = tva({
base: 'absolute left-0 top-0 right-0 bottom-0 web:cursor-default',
});
const popoverCloseButtonStyle = tva({
base: 'group/popover-close-button z-[1] rounded-sm data-[focus-visible=true]:web:bg-background-100 web:outline-0 web:cursor-pointer',
});
const popoverContentStyle = tva({
base: 'bg-background-0 rounded-lg overflow-hidden border border-outline-100 w-full',
parentVariants: {
size: {
xs: 'max-w-[360px] p-3.5',
sm: 'max-w-[420px] p-4',
md: 'max-w-[510px] p-[18px]',
lg: 'max-w-[640px] p-5',
full: 'p-6',
},
},
});
const popoverHeaderStyle = tva({
base: 'flex-row justify-between items-center',
});
const popoverBodyStyle = tva({
base: '',
});
const popoverFooterStyle = tva({
base: 'flex-row justify-between items-center',
});
type IPopoverProps = React.ComponentProps<typeof UIPopover> &
VariantProps<typeof popoverStyle> & { className?: string };
type IPopoverArrowProps = React.ComponentProps<typeof UIPopover.Arrow> &
VariantProps<typeof popoverArrowStyle> & { className?: string };
type IPopoverContentProps = React.ComponentProps<typeof UIPopover.Content> &
VariantProps<typeof popoverContentStyle> & { className?: string };
type IPopoverHeaderProps = React.ComponentProps<typeof UIPopover.Header> &
VariantProps<typeof popoverHeaderStyle> & { className?: string };
type IPopoverFooterProps = React.ComponentProps<typeof UIPopover.Footer> &
VariantProps<typeof popoverFooterStyle> & { className?: string };
type IPopoverBodyProps = React.ComponentProps<typeof UIPopover.Body> &
VariantProps<typeof popoverBodyStyle> & { className?: string };
type IPopoverBackdropProps = React.ComponentProps<typeof UIPopover.Backdrop> &
VariantProps<typeof popoverBackdropStyle> & { className?: string };
type IPopoverCloseButtonProps = React.ComponentProps<
typeof UIPopover.CloseButton
> &
VariantProps<typeof popoverCloseButtonStyle> & { className?: string };
const Popover = React.forwardRef<
React.ComponentRef<typeof UIPopover>,
IPopoverProps
>(function Popover(
{ className, size = 'md', placement = 'bottom', ...props },
ref
) {
return (
<UIPopover
ref={ref}
placement={placement}
{...props}
className={popoverStyle({ size, class: className })}
context={{ size, placement }}
pointerEvents="box-none"
/>
);
});
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof UIPopover.Content>,
IPopoverContentProps
>(function PopoverContent({ className, size, ...props }, ref) {
const { size: parentSize } = useStyleContext(SCOPE);
return (
<UIPopover.Content
ref={ref}
transition={{
type: 'spring',
damping: 18,
stiffness: 250,
mass: 0.9,
opacity: {
type: 'timing',
duration: 50,
delay: 50,
},
}}
{...props}
className={popoverContentStyle({
parentVariants: {
size: parentSize,
},
size,
class: className,
})}
pointerEvents="auto"
/>
);
});
const PopoverArrow = React.forwardRef<
React.ComponentRef<typeof UIPopover.Arrow>,
IPopoverArrowProps
>(function PopoverArrow({ className, ...props }, ref) {
const { placement } = useStyleContext(SCOPE);
return (
<UIPopover.Arrow
ref={ref}
transition={{
type: 'spring',
damping: 18,
stiffness: 250,
mass: 0.9,
opacity: {
type: 'timing',
duration: 50,
delay: 50,
},
}}
{...props}
className={popoverArrowStyle({
class: className,
placement,
})}
/>
);
});
const PopoverBackdrop = React.forwardRef<
React.ComponentRef<typeof UIPopover.Backdrop>,
IPopoverBackdropProps
>(function PopoverBackdrop({ className, ...props }, ref) {
return (
<UIPopover.Backdrop
ref={ref}
{...props}
initial={{
opacity: 0,
}}
animate={{
opacity: 0.1,
}}
exit={{
opacity: 0,
}}
transition={{
type: 'spring',
damping: 18,
stiffness: 450,
mass: 0.9,
opacity: {
type: 'timing',
duration: 50,
delay: 50,
},
}}
className={popoverBackdropStyle({
class: className,
})}
/>
);
});
const PopoverBody = React.forwardRef<
React.ComponentRef<typeof UIPopover.Body>,
IPopoverBodyProps
>(function PopoverBody({ className, ...props }, ref) {
return (
<UIPopover.Body
ref={ref}
{...props}
className={popoverBodyStyle({
class: className,
})}
/>
);
});
const PopoverCloseButton = React.forwardRef<
React.ComponentRef<typeof UIPopover.CloseButton>,
IPopoverCloseButtonProps
>(function PopoverCloseButton({ className, ...props }, ref) {
return (
<UIPopover.CloseButton
ref={ref}
{...props}
className={popoverCloseButtonStyle({
class: className,
})}
/>
);
});
const PopoverFooter = React.forwardRef<
React.ComponentRef<typeof UIPopover.Footer>,
IPopoverFooterProps
>(function PopoverFooter({ className, ...props }, ref) {
return (
<UIPopover.Footer
ref={ref}
{...props}
className={popoverFooterStyle({
class: className,
})}
/>
);
});
const PopoverHeader = React.forwardRef<
React.ComponentRef<typeof UIPopover.Header>,
IPopoverHeaderProps
>(function PopoverHeader({ className, ...props }, ref) {
return (
<UIPopover.Header
ref={ref}
{...props}
className={popoverHeaderStyle({
class: className,
})}
/>
);
});
Popover.displayName = 'Popover';
PopoverArrow.displayName = 'PopoverArrow';
PopoverBackdrop.displayName = 'PopoverBackdrop';
PopoverContent.displayName = 'PopoverContent';
PopoverHeader.displayName = 'PopoverHeader';
PopoverFooter.displayName = 'PopoverFooter';
PopoverBody.displayName = 'PopoverBody';
PopoverCloseButton.displayName = 'PopoverCloseButton';
export {
Popover,
PopoverBackdrop,
PopoverArrow,
PopoverCloseButton,
PopoverFooter,
PopoverHeader,
PopoverBody,
PopoverContent,
};

View File

@@ -1,131 +0,0 @@
'use client';
import React from 'react';
import { createTooltip } from '@gluestack-ui/core/tooltip/creator';
import { View, Text, ViewStyle } from 'react-native';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import { withStyleContext } from '@gluestack-ui/utils/nativewind-utils';
import {
Motion,
AnimatePresence,
MotionComponentProps,
} from '@legendapp/motion';
import { cssInterop } from 'nativewind';
type IMotionViewProps = React.ComponentProps<typeof View> &
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
export const UITooltip = createTooltip({
Root: withStyleContext(View),
Content: MotionView,
Text: Text,
AnimatePresence: AnimatePresence,
});
cssInterop(MotionView, { className: 'style' });
const tooltipStyle = tva({
base: 'w-full h-full web:pointer-events-none',
});
const tooltipContentStyle = tva({
base: 'py-1 px-3 rounded-sm bg-background-900 web:pointer-events-auto',
});
const tooltipTextStyle = tva({
base: 'font-normal tracking-normal web:select-none text-xs text-typography-50',
variants: {
isTruncated: {
true: 'line-clamp-1 truncate',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
size: {
'2xs': 'text-2xs',
'xs': 'text-xs',
'sm': 'text-sm',
'md': 'text-base',
'lg': 'text-lg',
'xl': 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl',
'5xl': 'text-5xl',
'6xl': 'text-6xl',
},
sub: {
true: 'text-xs',
},
italic: {
true: 'italic',
},
highlight: {
true: 'bg-yellow-500',
},
},
});
type ITooltipProps = React.ComponentProps<typeof UITooltip> &
VariantProps<typeof tooltipStyle> & { className?: string };
type ITooltipContentProps = React.ComponentProps<typeof UITooltip.Content> &
VariantProps<typeof tooltipContentStyle> & { className?: string };
type ITooltipTextProps = React.ComponentProps<typeof UITooltip.Text> &
VariantProps<typeof tooltipTextStyle> & { className?: string };
const Tooltip = React.forwardRef<
React.ComponentRef<typeof UITooltip>,
ITooltipProps
>(function Tooltip({ className, ...props }, ref) {
return (
<UITooltip
ref={ref}
className={tooltipStyle({ class: className })}
{...props}
/>
);
});
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof UITooltip.Content>,
ITooltipContentProps & { className?: string }
>(function TooltipContent({ className, ...props }, ref) {
return (
<UITooltip.Content
ref={ref}
{...props}
className={tooltipContentStyle({
class: className,
})}
pointerEvents="auto"
/>
);
});
const TooltipText = React.forwardRef<
React.ComponentRef<typeof UITooltip.Text>,
ITooltipTextProps & { className?: string }
>(function TooltipText({ size, className, ...props }, ref) {
return (
<UITooltip.Text
ref={ref}
className={tooltipTextStyle({ size, class: className })}
{...props}
/>
);
});
Tooltip.displayName = 'Tooltip';
TooltipContent.displayName = 'TooltipContent';
TooltipText.displayName = 'TooltipText';
export { Tooltip, TooltipContent, TooltipText };

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

@@ -3,19 +3,19 @@
* https://docs.expo.dev/guides/color-schemes/
*/
import { ColorName } from "@/constants/theme";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Colors } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme";
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: ColorName
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const { colorScheme, getColor } = useThemeContext();
const colorFromProps = props[colorScheme];
const theme = useColorScheme() ?? "light";
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return getColor(colorName);
return Colors[theme][colorName];
}
}

View File

@@ -1,26 +1,30 @@
/**
* Theme Context Hook for managing app-wide theme state
* Supports Light, Dark, and System (automatic) modes
* Theme Context Hook for managing app-wide theme state.
* Supports Light, Dark, and System (automatic) modes across Expo platforms.
*
* IMPORTANT: Requires expo-system-ui plugin in app.json for Android support
* IMPORTANT: Requires expo-system-ui plugin in app.json for Android status bar support.
*/
import { Colors, ColorName } from "@/constants/theme";
import { ColorName, Colors } from "@/constants/theme";
import { getStorageItem, setStorageItem } from "@/utils/storage";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
ReactNode,
} from "react";
import {
useColorScheme as useSystemColorScheme,
Appearance,
AppState,
AppStateStatus,
useColorScheme as useRNColorScheme,
} from "react-native";
type ThemeMode = "light" | "dark" | "system";
type ColorScheme = "light" | "dark";
export type ThemeMode = "light" | "dark" | "system";
export type ColorScheme = "light" | "dark";
interface ThemeContextType {
themeMode: ThemeMode;
@@ -28,146 +32,162 @@ interface ThemeContextType {
colors: typeof Colors.light;
setThemeMode: (mode: ThemeMode) => Promise<void>;
getColor: (colorName: ColorName) => string;
isHydrated: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const THEME_STORAGE_KEY = "theme_mode";
const getSystemScheme = (): ColorScheme => {
const scheme = Appearance.getColorScheme();
// console.log("[Theme] Appearance.getColorScheme():", scheme);
return scheme === "dark" ? "dark" : "light";
};
const isThemeMode = (value: unknown): value is ThemeMode => {
return value === "light" || value === "dark" || value === "system";
};
export function ThemeProvider({ children }: { children: ReactNode }) {
// State để force re-render khi system theme thay đổi
const [systemTheme, setSystemTheme] = useState<ColorScheme>(() => {
const current = Appearance.getColorScheme();
console.log("[Theme] Initial system theme:", current);
return current === "dark" ? "dark" : "light";
});
// State lưu user's choice (light/dark/system)
const [systemScheme, setSystemScheme] =
useState<ColorScheme>(getSystemScheme);
const [themeMode, setThemeModeState] = useState<ThemeMode>("system");
const [isLoaded, setIsLoaded] = useState(false);
const [isHydrated, setIsHydrated] = useState(false);
// Listen vào system theme changes - đăng ký ngay từ đầu
const syncSystemScheme = useCallback(() => {
const next = getSystemScheme();
// console.log("[Theme] syncSystemScheme computed:", next);
setSystemScheme((current) => (current === next ? current : next));
}, []);
const rnScheme = useRNColorScheme();
useEffect(() => {
console.log("[Theme] Registering appearance listener");
if (!rnScheme) return;
const next = rnScheme === "dark" ? "dark" : "light";
// console.log("[Theme] useColorScheme hook emitted:", rnScheme);
setSystemScheme((current) => (current === next ? current : next));
}, [rnScheme]);
useEffect(() => {
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
const newScheme = colorScheme === "dark" ? "dark" : "light";
console.log(
"[Theme] System theme changed to:",
newScheme,
"at",
new Date().toLocaleTimeString()
);
setSystemTheme(newScheme);
const next = colorScheme === "dark" ? "dark" : "light";
// console.log("[Theme] Appearance listener fired with:", colorScheme);
setSystemScheme((current) => (current === next ? current : next));
});
// Double check current theme khi mount
const currentScheme = Appearance.getColorScheme();
const current = currentScheme === "dark" ? "dark" : "light";
if (current !== systemTheme) {
console.log("[Theme] Syncing system theme on mount:", current);
setSystemTheme(current);
}
syncSystemScheme();
return () => {
console.log("[Theme] Removing appearance listener");
subscription.remove();
};
}, []);
}, [syncSystemScheme]);
// Xác định colorScheme cuối cùng
const colorScheme: ColorScheme =
themeMode === "system" ? systemTheme : themeMode;
const colors = Colors[colorScheme];
// Log để debug
useEffect(() => {
console.log(
"[Theme] Current state - Mode:",
themeMode,
"| Scheme:",
colorScheme,
"| System:",
systemTheme
// console.log("[Theme] System scheme detected:", systemScheme);
}, [systemScheme]);
useEffect(() => {
const handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState === "active") {
// console.log("[Theme] AppState active → scheduling system scheme sync");
setTimeout(() => {
// console.log("[Theme] AppState sync callback running");
syncSystemScheme();
}, 100);
}
};
const subscription = AppState.addEventListener(
"change",
handleAppStateChange
);
}, [themeMode, colorScheme, systemTheme]);
return () => subscription.remove();
}, [syncSystemScheme]);
useEffect(() => {
const loadThemeMode = async () => {
let isMounted = true;
const hydrateThemeMode = async () => {
try {
const savedThemeMode = await getStorageItem(THEME_STORAGE_KEY);
if (
savedThemeMode &&
["light", "dark", "system"].includes(savedThemeMode)
) {
setThemeModeState(savedThemeMode as ThemeMode);
if (isMounted && isThemeMode(savedThemeMode)) {
setThemeModeState(savedThemeMode);
}
} catch (error) {
console.warn("Failed to load theme mode:", error);
console.warn("[Theme] Failed to load theme mode:", error);
} finally {
setIsLoaded(true);
if (isMounted) {
setIsHydrated(true);
}
}
};
loadThemeMode();
hydrateThemeMode();
return () => {
isMounted = false;
};
}, []);
const setThemeMode = async (mode: ThemeMode) => {
try {
const colorScheme: ColorScheme =
themeMode === "system" ? systemScheme : themeMode;
const colors = useMemo(() => Colors[colorScheme], [colorScheme]);
const setThemeMode = useCallback(async (mode: ThemeMode) => {
setThemeModeState(mode);
try {
await setStorageItem(THEME_STORAGE_KEY, mode);
console.log("[Theme] Changed to:", mode);
} catch (error) {
console.warn("Failed to save theme mode:", error);
console.warn("[Theme] Failed to save theme mode:", error);
}
};
}, []);
const getColor = (colorName: ColorName): string => {
return colors[colorName] || colors.text;
};
useEffect(() => {
// console.log("[Theme] window defined:", typeof window !== "undefined");
}, []);
// Chờ theme load xong trước khi render
if (!isLoaded) {
// Render với default theme (system) khi đang load
return (
<ThemeContext.Provider
value={{
themeMode: "system",
colorScheme: systemTheme,
colors: Colors[systemTheme],
setThemeMode: async () => {},
getColor: (colorName: ColorName) =>
Colors[systemTheme][colorName] || Colors[systemTheme].text,
}}
>
{children}
</ThemeContext.Provider>
const getColor = useCallback(
(colorName: ColorName) => colors[colorName] ?? colors.text,
[colors]
);
}
const value: ThemeContextType = {
useEffect(() => {
// console.log("[Theme] Mode:", themeMode);
}, [themeMode]);
useEffect(() => {
// console.log("[Theme] Derived colorScheme:", colorScheme);
}, [colorScheme]);
const value = useMemo(
() => ({
themeMode,
colorScheme,
colors,
setThemeMode,
getColor,
};
isHydrated,
}),
[themeMode, colorScheme, colors, setThemeMode, getColor, isHydrated]
);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useThemeContext(): ThemeContextType {
export function useTheme(): ThemeContextType {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error("useThemeContext must be used within a ThemeProvider");
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
// Legacy hook cho backward compatibility
export const useThemeContext = useTheme;
export function useColorScheme(): ColorScheme {
const { colorScheme } = useThemeContext();
return colorScheme;
return useTheme().colorScheme;
}

50
package-lock.json generated
View File

@@ -10,8 +10,6 @@
"dependencies": {
"@expo/html-elements": "^0.10.1",
"@expo/vector-icons": "^15.0.3",
"@gluestack-ui/core": "^3.0.12",
"@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2",
"@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3",
@@ -2357,43 +2355,6 @@
"tslib": "^2.8.0"
}
},
"node_modules/@gluestack-ui/core": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/@gluestack-ui/core/-/core-3.0.12.tgz",
"integrity": "sha512-TyNjDUJrZF/FTqcSEPBR87wZQ3yvbWuTjn0tG5AFYzYfMCw0IpfTigmzoajN9KHensN0xNwHoAkXKaHlhy11yQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@gluestack-ui/utils": ">=2.0.0",
"react": ">=16.8.0",
"react-native": ">=0.64.0",
"react-native-safe-area-context": ">=4.0.0",
"react-native-svg": ">=12.0.0",
"react-native-web": ">=0.19.0"
}
},
"node_modules/@gluestack-ui/utils": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@gluestack-ui/utils/-/utils-3.0.11.tgz",
"integrity": "sha512-4stxK98v07NFAGvSI4Dxje/xbnftaY45VcZglZUxlAr8FFVLNFcjXUTSnVWqog0DBp2oJ7Nk/AYUpT2KkpI+7A==",
"dependencies": {
"dom-helpers": "^6.0.1",
"react-aria": "^3.41.1",
"react-stately": "^3.39.0",
"tailwind-variants": "0.1.20"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-native": ">=0.64.0",
"react-native-web": ">=0.19.0",
"tailwindcss": ">=3.0.0"
}
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
@@ -7379,6 +7340,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -7625,16 +7587,6 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-helpers": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-6.0.1.tgz",
"integrity": "sha512-IKySryuFwseGkrCA/pIqlwUPOD50w1Lj/B2Yief3vBOP18k5y4t+hTqKh55gULDVeJMRitcozve+g/wVFf4sFQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.1",
"csstype": "^3.1.3"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",

View File

@@ -13,8 +13,6 @@
"dependencies": {
"@expo/html-elements": "^0.10.1",
"@expo/vector-icons": "^15.0.3",
"@gluestack-ui/core": "^3.0.12",
"@gluestack-ui/utils": "^3.0.11",
"@hookform/resolvers": "^5.2.2",
"@islacel/react-native-custom-switch": "^1.0.10",
"@legendapp/motion": "^2.5.3",