933 lines
26 KiB
TypeScript
933 lines
26 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
Modal,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
Platform,
|
|
TextInput,
|
|
ScrollView,
|
|
ActivityIndicator,
|
|
Animated,
|
|
Dimensions,
|
|
Alert,
|
|
Image,
|
|
} from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useI18n } from "@/hooks/use-i18n";
|
|
import { useThemeContext } from "@/hooks/use-theme-context";
|
|
import {
|
|
searchCrew,
|
|
newTripCrew,
|
|
updateTripCrew,
|
|
} from "@/controller/TripCrewController";
|
|
import {
|
|
newCrew,
|
|
updateCrewInfo,
|
|
queryCrewImage,
|
|
} from "@/controller/CrewController";
|
|
import * as ImagePicker from "expo-image-picker";
|
|
import { Buffer } from "buffer";
|
|
|
|
interface CrewFormData {
|
|
personalId: string;
|
|
name: string;
|
|
phone: string;
|
|
email: string;
|
|
address: string;
|
|
role: string;
|
|
note: string;
|
|
}
|
|
|
|
interface AddEditCrewModalProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
onSave: (data: CrewFormData) => Promise<void>;
|
|
mode: "add" | "edit";
|
|
initialData?: Partial<CrewFormData>;
|
|
tripId?: string; // Required for add mode to add crew to trip
|
|
existingCrewIds?: string[]; // List of existing crew IDs in trip
|
|
}
|
|
|
|
const ROLES = ["captain", "crew"];
|
|
const DEBOUNCE_DELAY = 1000; // 3 seconds debounce
|
|
|
|
export default function AddEditCrewModal({
|
|
visible,
|
|
onClose,
|
|
onSave,
|
|
mode,
|
|
initialData,
|
|
tripId,
|
|
existingCrewIds = [],
|
|
}: AddEditCrewModalProps) {
|
|
const { t } = useI18n();
|
|
const { colors } = useThemeContext();
|
|
|
|
// Animation values
|
|
const fadeAnim = useState(new Animated.Value(0))[0];
|
|
const slideAnim = useState(
|
|
new Animated.Value(Dimensions.get("window").height)
|
|
)[0];
|
|
|
|
// Form state
|
|
const [formData, setFormData] = useState<CrewFormData>({
|
|
personalId: "",
|
|
name: "",
|
|
phone: "",
|
|
email: "",
|
|
address: "",
|
|
role: "crew",
|
|
note: "",
|
|
});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [isWaitingDebounce, setIsWaitingDebounce] = useState(false);
|
|
const [searchStatus, setSearchStatus] = useState<
|
|
"idle" | "found" | "not_found" | "error"
|
|
>("idle");
|
|
const [foundPersonData, setFoundPersonData] =
|
|
useState<Model.TripCrewPerson | null>(null);
|
|
|
|
// State quản lý ảnh thuyền viên
|
|
const [imageUri, setImageUri] = useState<string | null>(null);
|
|
const [newImageUri, setNewImageUri] = useState<string | null>(null);
|
|
const [isLoadingImage, setIsLoadingImage] = useState(false);
|
|
|
|
// Debounce timer ref
|
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Pre-fill form when editing
|
|
useEffect(() => {
|
|
if (visible && initialData) {
|
|
setFormData({
|
|
personalId: initialData.personalId || "",
|
|
name: initialData.name || "",
|
|
phone: initialData.phone || "",
|
|
email: initialData.email || "",
|
|
address: initialData.address || "",
|
|
role: initialData.role || "crew",
|
|
note: initialData.note || "",
|
|
});
|
|
setSearchStatus("idle");
|
|
setFoundPersonData(null);
|
|
// Reset ảnh khi mở modal edit
|
|
setNewImageUri(null);
|
|
setImageUri(null);
|
|
} else if (visible && mode === "add") {
|
|
// Reset form for add mode
|
|
setFormData({
|
|
personalId: "",
|
|
name: "",
|
|
phone: "",
|
|
email: "",
|
|
address: "",
|
|
role: "crew",
|
|
note: "",
|
|
});
|
|
setSearchStatus("idle");
|
|
setFoundPersonData(null);
|
|
// Reset ảnh
|
|
setNewImageUri(null);
|
|
setImageUri(null);
|
|
}
|
|
}, [visible, initialData, mode]);
|
|
|
|
// Load ảnh thuyền viên khi edit
|
|
useEffect(() => {
|
|
const loadCrewImage = async () => {
|
|
if (visible && mode === "edit" && initialData?.personalId) {
|
|
setIsLoadingImage(true);
|
|
try {
|
|
const response = await queryCrewImage(initialData.personalId);
|
|
if (response.data) {
|
|
const base64 = Buffer.from(response.data as ArrayBuffer).toString(
|
|
"base64"
|
|
);
|
|
setImageUri(`data:image/jpeg;base64,${base64}`);
|
|
}
|
|
} catch (error) {
|
|
// Không có ảnh hoặc lỗi - hiển thị placeholder
|
|
setImageUri(null);
|
|
} finally {
|
|
setIsLoadingImage(false);
|
|
}
|
|
}
|
|
};
|
|
loadCrewImage();
|
|
}, [visible, mode, initialData?.personalId]);
|
|
|
|
// Cleanup debounce timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Search crew by personal ID with debounce
|
|
const searchCrewByPersonalId = useCallback(async (personalId: string) => {
|
|
if (!personalId.trim() || personalId.length < 5) {
|
|
setSearchStatus("idle");
|
|
return;
|
|
}
|
|
|
|
setIsSearching(true);
|
|
setSearchStatus("idle");
|
|
|
|
try {
|
|
const response = await searchCrew(personalId.trim());
|
|
if (response.data) {
|
|
const person = response.data;
|
|
setFoundPersonData(person);
|
|
setSearchStatus("found");
|
|
|
|
// Auto-fill form with found data
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
name: person.name || prev.name,
|
|
phone: person.phone || prev.phone,
|
|
email: person.email || prev.email,
|
|
address: person.address || prev.address,
|
|
note: person.note || prev.note,
|
|
}));
|
|
} else {
|
|
setSearchStatus("not_found");
|
|
setFoundPersonData(null);
|
|
}
|
|
} catch (error: any) {
|
|
// 404 or 401 means not found or unauthorized - treat as not found silently
|
|
const status = error?.response?.status;
|
|
if (status === 404 || status === 401) {
|
|
setSearchStatus("not_found");
|
|
} else {
|
|
// For other errors, just set to not_found without logging to console
|
|
setSearchStatus("not_found");
|
|
}
|
|
setFoundPersonData(null);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
// Handle personal ID change with debounce
|
|
const handlePersonalIdChange = (value: string) => {
|
|
// If crew was previously found and user is changing personal ID, reset form
|
|
if (foundPersonData && value !== formData.personalId) {
|
|
setFormData({
|
|
personalId: value,
|
|
name: "",
|
|
phone: "",
|
|
email: "",
|
|
address: "",
|
|
role: "crew",
|
|
note: "",
|
|
});
|
|
setFoundPersonData(null);
|
|
} else {
|
|
setFormData((prev) => ({ ...prev, personalId: value }));
|
|
}
|
|
|
|
setSearchStatus("idle");
|
|
setIsWaitingDebounce(false);
|
|
|
|
// Clear previous timer
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
|
|
// Only search in add mode and when value is long enough
|
|
if (mode === "add" && value.trim().length >= 5) {
|
|
// Show waiting indicator
|
|
setIsWaitingDebounce(true);
|
|
|
|
// Set new timer
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
setIsWaitingDebounce(false);
|
|
searchCrewByPersonalId(value);
|
|
}, DEBOUNCE_DELAY);
|
|
}
|
|
};
|
|
|
|
// Chọn ảnh từ thư viện
|
|
const pickImage = async () => {
|
|
// Xin quyền truy cập thư viện ảnh
|
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (status !== "granted") {
|
|
Alert.alert(t("common.error"), "Cần cấp quyền truy cập thư viện ảnh");
|
|
return;
|
|
}
|
|
|
|
// Mở image picker
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ["images"],
|
|
allowsEditing: true,
|
|
aspect: [1, 1],
|
|
quality: 0.8,
|
|
});
|
|
|
|
if (!result.canceled && result.assets[0]) {
|
|
setNewImageUri(result.assets[0].uri);
|
|
}
|
|
};
|
|
|
|
// Handle animation
|
|
useEffect(() => {
|
|
if (visible) {
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 1,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(slideAnim, {
|
|
toValue: 0,
|
|
duration: 300,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
}
|
|
}, [visible, fadeAnim, slideAnim]);
|
|
|
|
const handleClose = () => {
|
|
// Clear debounce timer
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 0,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(slideAnim, {
|
|
toValue: Dimensions.get("window").height,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start(() => {
|
|
onClose();
|
|
});
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
// === VALIDATE DỮ LIỆU ===
|
|
if (!formData.personalId.trim()) {
|
|
Alert.alert(t("common.error"), t("diary.crew.form.personalIdRequired"));
|
|
return;
|
|
}
|
|
if (!formData.name.trim()) {
|
|
Alert.alert(t("common.error"), t("diary.crew.form.nameRequired"));
|
|
return;
|
|
}
|
|
|
|
// Kiểm tra thuyền viên đã có trong chuyến đi chưa (chỉ với mode add)
|
|
if (
|
|
mode === "add" &&
|
|
existingCrewIds.includes(formData.personalId.trim())
|
|
) {
|
|
Alert.alert(t("common.error"), t("diary.crew.form.crewAlreadyExists"));
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
if (mode === "add" && tripId) {
|
|
// === CHẾ ĐỘ THÊM MỚI ===
|
|
|
|
// Bước 1: Nếu không tìm thấy thuyền viên trong hệ thống -> tạo mới
|
|
if (searchStatus === "not_found" || !foundPersonData) {
|
|
await newCrew({
|
|
personal_id: formData.personalId.trim(),
|
|
name: formData.name.trim(),
|
|
phone: formData.phone || "",
|
|
email: formData.email || "",
|
|
birth_date: new Date(),
|
|
note: formData.note || "",
|
|
address: formData.address || "",
|
|
});
|
|
}
|
|
|
|
// Bước 2: Thêm thuyền viên vào chuyến đi với role
|
|
await newTripCrew({
|
|
trip_id: tripId,
|
|
personal_id: formData.personalId.trim(),
|
|
role: formData.role as "captain" | "crew",
|
|
});
|
|
} else if (mode === "edit" && tripId) {
|
|
// === CHẾ ĐỘ CHỈNH SỬA ===
|
|
|
|
// Bước 1: Cập nhật thông tin cá nhân của thuyền viên (không bao gồm note)
|
|
await updateCrewInfo(formData.personalId.trim(), {
|
|
name: formData.name.trim(),
|
|
phone: formData.phone || "",
|
|
email: formData.email || "",
|
|
birth_date: new Date(),
|
|
address: formData.address || "",
|
|
});
|
|
|
|
// Bước 2: Cập nhật role và note của thuyền viên trong chuyến đi
|
|
await updateTripCrew({
|
|
trip_id: tripId,
|
|
personal_id: formData.personalId.trim(),
|
|
role: formData.role as "captain" | "crew",
|
|
note: formData.note || "",
|
|
});
|
|
}
|
|
|
|
// Gọi callback để reload danh sách
|
|
await onSave(formData);
|
|
handleClose();
|
|
} catch (error: any) {
|
|
console.error("Lỗi khi lưu thuyền viên:", error);
|
|
Alert.alert(t("common.error"), t("diary.crew.form.saveError"));
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const updateField = (field: keyof CrewFormData, value: string) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
// Render search status message
|
|
const renderSearchStatus = () => {
|
|
if (isSearching) {
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.statusContainer,
|
|
{ backgroundColor: colors.backgroundSecondary },
|
|
]}
|
|
>
|
|
<ActivityIndicator size="small" color={colors.primary} />
|
|
<Text style={[styles.statusText, { color: colors.textSecondary }]}>
|
|
{t("diary.crew.form.searching")}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (searchStatus === "found") {
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.statusContainer,
|
|
styles.statusSuccess,
|
|
{ backgroundColor: colors.success + "15" },
|
|
]}
|
|
>
|
|
<Ionicons name="checkmark-circle" size={18} color={colors.success} />
|
|
<Text style={[styles.statusText, { color: colors.success }]}>
|
|
{t("diary.crew.form.crewFound")}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (searchStatus === "not_found") {
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.statusContainer,
|
|
styles.statusWarning,
|
|
{ backgroundColor: colors.warning + "15" },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name="information-circle"
|
|
size={18}
|
|
color={colors.warning}
|
|
/>
|
|
<Text style={[styles.statusText, { color: colors.warning }]}>
|
|
{t("diary.crew.form.crewNotFound")}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const themedStyles = {
|
|
modalContainer: { backgroundColor: colors.card },
|
|
header: { borderBottomColor: colors.separator },
|
|
title: { color: colors.text },
|
|
label: { color: colors.text },
|
|
input: {
|
|
backgroundColor: colors.backgroundSecondary,
|
|
color: colors.text,
|
|
borderColor: colors.separator,
|
|
},
|
|
placeholder: { color: colors.textSecondary },
|
|
roleButton: {
|
|
backgroundColor: colors.backgroundSecondary,
|
|
borderColor: colors.separator,
|
|
},
|
|
roleButtonActive: {
|
|
backgroundColor: colors.primary + "20",
|
|
borderColor: colors.primary,
|
|
},
|
|
roleText: { color: colors.textSecondary },
|
|
roleTextActive: { color: colors.primary },
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
animationType="none"
|
|
transparent
|
|
onRequestClose={handleClose}
|
|
>
|
|
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
|
<Animated.View
|
|
style={[
|
|
styles.modalContainer,
|
|
themedStyles.modalContainer,
|
|
{ transform: [{ translateY: slideAnim }] },
|
|
]}
|
|
>
|
|
{/* Header */}
|
|
<View style={[styles.header, themedStyles.header]}>
|
|
<TouchableOpacity onPress={handleClose} style={styles.closeButton}>
|
|
<Ionicons name="close" size={24} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<Text style={[styles.title, themedStyles.title]}>
|
|
{mode === "add"
|
|
? t("diary.crew.form.addTitle")
|
|
: t("diary.crew.form.editTitle")}
|
|
</Text>
|
|
<View style={styles.headerPlaceholder} />
|
|
</View>
|
|
|
|
{/* Content */}
|
|
<ScrollView
|
|
style={styles.content}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Ảnh thuyền viên */}
|
|
<View style={styles.photoSection}>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.avatarContainer,
|
|
{ borderColor: colors.separator },
|
|
]}
|
|
onPress={pickImage}
|
|
activeOpacity={0.8}
|
|
>
|
|
{isLoadingImage ? (
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
) : newImageUri || imageUri ? (
|
|
<Image
|
|
source={{ uri: newImageUri || imageUri || "" }}
|
|
style={styles.avatar}
|
|
/>
|
|
) : (
|
|
<View
|
|
style={[
|
|
styles.avatarPlaceholder,
|
|
{ backgroundColor: colors.backgroundSecondary },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name="person"
|
|
size={50}
|
|
color={colors.textSecondary}
|
|
/>
|
|
</View>
|
|
)}
|
|
</TouchableOpacity>
|
|
<Text style={[styles.photoHint, { color: colors.textSecondary }]}>
|
|
Nhấn để chọn ảnh
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Personal ID */}
|
|
<View style={styles.formGroup}>
|
|
<Text style={[styles.label, themedStyles.label]}>
|
|
{t("diary.crew.personalId")} *
|
|
</Text>
|
|
<View style={styles.inputWithIcon}>
|
|
<TextInput
|
|
style={[
|
|
styles.input,
|
|
styles.inputWithIconInput,
|
|
themedStyles.input,
|
|
]}
|
|
value={formData.personalId}
|
|
onChangeText={handlePersonalIdChange}
|
|
placeholder={t("diary.crew.form.personalIdPlaceholder")}
|
|
placeholderTextColor={themedStyles.placeholder.color}
|
|
editable={mode === "add"}
|
|
/>
|
|
{/* Show waiting icon during debounce, spinner during search */}
|
|
{(isWaitingDebounce || isSearching) && (
|
|
<View style={styles.inputIcon}>
|
|
{isSearching ? (
|
|
<ActivityIndicator size="small" color={colors.primary} />
|
|
) : (
|
|
<Ionicons
|
|
name="time-outline"
|
|
size={20}
|
|
color={colors.textSecondary}
|
|
/>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
{mode === "add" && (
|
|
<Text style={[styles.hint, { color: colors.textSecondary }]}>
|
|
{isWaitingDebounce
|
|
? t("diary.crew.form.waitingSearch")
|
|
: t("diary.crew.form.searchHint")}
|
|
</Text>
|
|
)}
|
|
{renderSearchStatus()}
|
|
</View>
|
|
|
|
{/* Name */}
|
|
<View style={styles.formGroup}>
|
|
<Text style={[styles.label, themedStyles.label]}>
|
|
{t("diary.crew.form.name")} *
|
|
</Text>
|
|
<TextInput
|
|
style={[styles.input, themedStyles.input]}
|
|
value={formData.name}
|
|
onChangeText={(v) => updateField("name", v)}
|
|
placeholder={t("diary.crew.form.namePlaceholder")}
|
|
placeholderTextColor={themedStyles.placeholder.color}
|
|
/>
|
|
</View>
|
|
|
|
{/* Phone */}
|
|
<View style={styles.formGroup}>
|
|
<Text style={[styles.label, themedStyles.label]}>
|
|
{t("diary.crew.phone")}
|
|
</Text>
|
|
<TextInput
|
|
style={[styles.input, themedStyles.input]}
|
|
value={formData.phone}
|
|
onChangeText={(v) => updateField("phone", v)}
|
|
placeholder={t("diary.crew.form.phonePlaceholder")}
|
|
placeholderTextColor={themedStyles.placeholder.color}
|
|
keyboardType="phone-pad"
|
|
/>
|
|
</View>
|
|
|
|
{/* Role */}
|
|
<View style={styles.formGroup}>
|
|
<Text style={[styles.label, themedStyles.label]}>
|
|
{t("diary.crew.form.role")}
|
|
</Text>
|
|
<View style={styles.roleContainer}>
|
|
{ROLES.map((role) => (
|
|
<TouchableOpacity
|
|
key={role}
|
|
style={[
|
|
styles.roleButton,
|
|
themedStyles.roleButton,
|
|
formData.role === role && themedStyles.roleButtonActive,
|
|
]}
|
|
onPress={() => updateField("role", role)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.roleButtonText,
|
|
themedStyles.roleText,
|
|
formData.role === role && themedStyles.roleTextActive,
|
|
]}
|
|
>
|
|
{t(`diary.crew.roles.${role}`)}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{/* Address */}
|
|
<View style={styles.formGroup}>
|
|
<Text style={[styles.label, themedStyles.label]}>
|
|
{t("diary.crew.form.address")}
|
|
</Text>
|
|
<TextInput
|
|
style={[styles.input, themedStyles.input]}
|
|
value={formData.address}
|
|
onChangeText={(v) => updateField("address", v)}
|
|
placeholder={t("diary.crew.form.addressPlaceholder")}
|
|
placeholderTextColor={themedStyles.placeholder.color}
|
|
/>
|
|
</View>
|
|
|
|
{/* Note */}
|
|
<View style={styles.formGroup}>
|
|
<Text style={[styles.label, themedStyles.label]}>
|
|
{t("diary.crew.note")}
|
|
</Text>
|
|
<TextInput
|
|
style={[styles.input, styles.textArea, themedStyles.input]}
|
|
value={formData.note}
|
|
onChangeText={(v) => updateField("note", v)}
|
|
placeholder={t("diary.crew.form.notePlaceholder")}
|
|
placeholderTextColor={themedStyles.placeholder.color}
|
|
multiline
|
|
numberOfLines={3}
|
|
/>
|
|
</View>
|
|
</ScrollView>
|
|
|
|
{/* Footer */}
|
|
<View style={[styles.footer, { borderTopColor: colors.separator }]}>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.cancelButton,
|
|
{ backgroundColor: colors.backgroundSecondary },
|
|
]}
|
|
onPress={handleClose}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.cancelButtonText,
|
|
{ color: colors.textSecondary },
|
|
]}
|
|
>
|
|
{t("common.cancel")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.saveButton,
|
|
{ backgroundColor: colors.primary },
|
|
isSubmitting && styles.buttonDisabled,
|
|
]}
|
|
onPress={handleSave}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting ? (
|
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
|
) : (
|
|
<Text style={styles.saveButtonText}>{t("common.save")}</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
</View>
|
|
</Animated.View>
|
|
</Animated.View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
overlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
justifyContent: "flex-end",
|
|
},
|
|
modalContainer: {
|
|
borderTopLeftRadius: 24,
|
|
borderTopRightRadius: 24,
|
|
maxHeight: "90%",
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 16,
|
|
borderBottomWidth: 1,
|
|
},
|
|
closeButton: {
|
|
padding: 4,
|
|
},
|
|
title: {
|
|
fontSize: 18,
|
|
fontWeight: "700",
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
headerPlaceholder: {
|
|
width: 32,
|
|
},
|
|
content: {
|
|
padding: 20,
|
|
},
|
|
formGroup: {
|
|
marginBottom: 16,
|
|
},
|
|
label: {
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
marginBottom: 8,
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
input: {
|
|
height: 44,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
paddingHorizontal: 14,
|
|
fontSize: 15,
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
inputWithIcon: {
|
|
position: "relative",
|
|
},
|
|
inputWithIconInput: {
|
|
paddingRight: 44,
|
|
},
|
|
inputIcon: {
|
|
position: "absolute",
|
|
right: 12,
|
|
top: 0,
|
|
bottom: 0,
|
|
justifyContent: "center",
|
|
},
|
|
hint: {
|
|
fontSize: 12,
|
|
marginTop: 4,
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
statusContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
marginTop: 8,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
borderRadius: 8,
|
|
},
|
|
statusSuccess: {},
|
|
statusWarning: {},
|
|
statusText: {
|
|
fontSize: 13,
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
textArea: {
|
|
height: 80,
|
|
paddingTop: 12,
|
|
textAlignVertical: "top",
|
|
},
|
|
roleContainer: {
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
gap: 8,
|
|
},
|
|
roleButton: {
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 8,
|
|
borderRadius: 20,
|
|
borderWidth: 1,
|
|
},
|
|
roleButtonText: {
|
|
fontSize: 13,
|
|
fontWeight: "500",
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
footer: {
|
|
flexDirection: "row",
|
|
gap: 12,
|
|
padding: 20,
|
|
borderTopWidth: 1,
|
|
},
|
|
cancelButton: {
|
|
flex: 1,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
alignItems: "center",
|
|
},
|
|
cancelButtonText: {
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
saveButton: {
|
|
flex: 1,
|
|
paddingVertical: 14,
|
|
borderRadius: 12,
|
|
alignItems: "center",
|
|
},
|
|
saveButtonText: {
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
color: "#FFFFFF",
|
|
fontFamily: Platform.select({
|
|
ios: "System",
|
|
android: "Roboto",
|
|
default: "System",
|
|
}),
|
|
},
|
|
buttonDisabled: {
|
|
opacity: 0.7,
|
|
},
|
|
// Styles cho phần ảnh thuyền viên
|
|
photoSection: {
|
|
alignItems: "center",
|
|
marginBottom: 20,
|
|
},
|
|
avatarContainer: {
|
|
width: 100,
|
|
height: 100,
|
|
borderRadius: 50,
|
|
borderWidth: 2,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
overflow: "hidden",
|
|
position: "relative",
|
|
},
|
|
avatar: {
|
|
width: 100,
|
|
height: 100,
|
|
borderRadius: 50,
|
|
},
|
|
avatarPlaceholder: {
|
|
width: 100,
|
|
height: 100,
|
|
borderRadius: 50,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
cameraIconOverlay: {
|
|
position: "absolute",
|
|
bottom: 0,
|
|
right: 0,
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
borderWidth: 2,
|
|
borderColor: "#FFFFFF",
|
|
},
|
|
photoHint: {
|
|
fontSize: 12,
|
|
marginTop: 8,
|
|
},
|
|
});
|