thêm tab "Xem chi tiết chuyến đi", "Xem chi tiết thành viên chuyến đi", tái sử dụng lại components modal tripForm

This commit is contained in:
2025-12-23 23:10:19 +07:00
parent afc6acbfe2
commit 000a4ed856
22 changed files with 3221 additions and 379 deletions

312
app/trip-detail.tsx Normal file
View File

@@ -0,0 +1,312 @@
import React, { useEffect, useState, useMemo } from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
ScrollView,
ActivityIndicator,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { TRIP_STATUS_CONFIG } from "@/components/diary/types";
import {
convertFishingGears,
convertTripCosts,
} from "@/utils/tripDataConverters";
// Reuse existing components
import CrewList from "@/components/diary/TripCrewModal/CrewList";
import FishingGearList from "@/components/diary/TripFormModal/FishingGearList";
import MaterialCostList from "@/components/diary/TripFormModal/MaterialCostList";
// Section components
import {
SectionCard,
AlertsSection,
FishingLogsSection,
BasicInfoSection,
} from "@/components/diary/TripDetailSections";
export default function TripDetailPage() {
const { t } = useI18n();
const { colors } = useThemeContext();
const router = useRouter();
const { tripId, tripData: tripDataParam } = useLocalSearchParams<{
tripId: string;
tripData?: string;
}>();
const [loading, setLoading] = useState(true);
const [trip, setTrip] = useState<Model.Trip | null>(null);
const [alerts] = useState<Model.Alarm[]>([]); // TODO: Fetch from API
// Parse trip data from params or fetch from API
useEffect(() => {
if (tripDataParam) {
try {
const parsedTrip = JSON.parse(tripDataParam) as Model.Trip;
setTrip(parsedTrip);
} catch (e) {
console.error("Error parsing trip data:", e);
}
}
// TODO: Fetch trip detail from API using tripId if not passed via params
setLoading(false);
}, [tripDataParam, tripId]);
// Convert trip data to component format using memoization
const fishingGears = useMemo(
() => convertFishingGears(trip?.fishing_gears, "view"),
[trip?.fishing_gears]
);
const tripCosts = useMemo(
() => convertTripCosts(trip?.trip_cost, "view"),
[trip?.trip_cost]
);
const statusConfig = useMemo(() => {
const status = trip?.trip_status ?? 0;
return TRIP_STATUS_CONFIG[status as keyof typeof TRIP_STATUS_CONFIG] || TRIP_STATUS_CONFIG[0];
}, [trip?.trip_status]);
// Empty section component
const EmptySection = ({ icon, message }: { icon: string; message: string }) => (
<View style={styles.emptySection}>
<Ionicons name={icon as any} size={40} color={colors.textSecondary} />
<Text style={[styles.emptyText, { color: colors.textSecondary }]}>
{message}
</Text>
</View>
);
// Render loading state
if (loading) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
{t("common.loading")}
</Text>
</View>
</SafeAreaView>
);
}
// Render error state
if (!trip) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}>
<Header title={t("diary.tripDetail.title")} onBack={() => router.back()} colors={colors} />
<View style={styles.errorContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} />
<Text style={[styles.errorText, { color: colors.textSecondary }]}>
{t("diary.tripDetail.notFound")}
</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]} edges={["top"]}>
{/* Header with status badge */}
<View style={[styles.header, { backgroundColor: colors.card, borderBottomColor: colors.separator }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, { color: colors.text }]} numberOfLines={1}>
{trip.name || t("diary.tripDetail.title")}
</Text>
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
<Ionicons name={statusConfig.icon as any} size={12} color={statusConfig.textColor} />
<Text style={[styles.statusText, { color: statusConfig.textColor }]}>
{statusConfig.label}
</Text>
</View>
</View>
<View style={styles.placeholder} />
</View>
{/* Content */}
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* Basic Info */}
<BasicInfoSection trip={trip} />
{/* Alerts */}
<AlertsSection alerts={alerts} />
{/* Trip Costs */}
<SectionCard
title={t("diary.tripDetail.costs")}
icon="wallet-outline"
count={tripCosts.length}
collapsible
defaultExpanded
>
{tripCosts.length > 0 ? (
<View style={styles.sectionInnerContent}>
<MaterialCostList items={tripCosts} onChange={() => {}} disabled />
</View>
) : (
<EmptySection icon="receipt-outline" message={t("diary.tripDetail.noCosts")} />
)}
</SectionCard>
{/* Fishing Gears */}
<SectionCard
title={t("diary.tripDetail.gears")}
icon="construct-outline"
count={fishingGears.length}
collapsible
defaultExpanded
>
{fishingGears.length > 0 ? (
<View style={styles.sectionInnerContent}>
<FishingGearList items={fishingGears} onChange={() => {}} disabled />
</View>
) : (
<EmptySection icon="build-outline" message={t("diary.tripDetail.noGears")} />
)}
</SectionCard>
{/* Crew List */}
<SectionCard
title={t("diary.tripDetail.crew")}
icon="people-outline"
count={trip.crews?.length || 0}
collapsible
defaultExpanded
>
{trip.crews && trip.crews.length > 0 ? (
<CrewList crews={trip.crews} />
) : (
<EmptySection icon="person-add-outline" message={t("diary.tripDetail.noCrew")} />
)}
</SectionCard>
{/* Fishing Logs */}
<FishingLogsSection fishingLogs={trip.fishing_logs} />
</ScrollView>
</SafeAreaView>
);
}
// Header component for reuse
function Header({
title,
onBack,
colors,
}: {
title: string;
onBack: () => void;
colors: any;
}) {
return (
<View style={[styles.header, { backgroundColor: colors.card, borderBottomColor: colors.separator }]}>
<TouchableOpacity onPress={onBack} style={styles.backButton}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerTitles}>
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
</View>
<View style={styles.placeholder} />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
},
loadingText: {
fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
},
backButton: {
padding: 4,
},
headerTitles: {
flex: 1,
alignItems: "center",
gap: 6,
},
title: {
fontSize: 18,
fontWeight: "700",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
statusBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: 11,
fontWeight: "600",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
placeholder: {
width: 32,
},
content: {
flex: 1,
},
contentContainer: {
padding: 16,
paddingBottom: 40,
},
errorContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: 12,
paddingHorizontal: 20,
},
errorText: {
fontSize: 14,
textAlign: "center",
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
sectionInnerContent: {
marginTop: -8,
},
emptySection: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 24,
gap: 8,
},
emptyText: {
fontSize: 14,
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
},
});