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:
@@ -2,15 +2,14 @@ import { Tabs, useSegments } from "expo-router";
|
||||
|
||||
import { HapticTab } from "@/components/haptic-tab";
|
||||
import { IconSymbol } from "@/components/ui/icon-symbol";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { queryProfile } from "@/controller/AuthController";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { useColorScheme } from "@/hooks/use-theme-context";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { addUserStorage } from "@/utils/storage";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme();
|
||||
const { colors, colorScheme } = useThemeContext();
|
||||
const segments = useSegments() as string[];
|
||||
const prev = useRef<string | null>(null);
|
||||
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
|
||||
@@ -51,9 +50,18 @@ export default function TabLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
|
||||
tabBarActiveTintColor: colors.tint,
|
||||
headerShown: false,
|
||||
tabBarButton: HapticTab,
|
||||
// Set tab bar styles based on theme - prevents white flash
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopColor: colors.separator,
|
||||
},
|
||||
// Set screen content background
|
||||
sceneStyle: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useRouter } from "expo-router";
|
||||
import FilterButton from "@/components/diary/FilterButton";
|
||||
import TripCard from "@/components/diary/TripCard";
|
||||
import FilterModal, { FilterValues } from "@/components/diary/FilterModal";
|
||||
@@ -23,10 +24,10 @@ import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
export default function diary() {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
const router = useRouter();
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
||||
const [editingTrip, setEditingTrip] = useState<Model.Trip | null>(null);
|
||||
const [viewingTrip, setViewingTrip] = useState<Model.Trip | null>(null);
|
||||
const [filters, setFilters] = useState<FilterValues>({
|
||||
status: null,
|
||||
startDate: null,
|
||||
@@ -40,6 +41,10 @@ export default function diary() {
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const isInitialLoad = useRef(true);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
// Refs to prevent duplicate API calls from React Compiler/Strict Mode
|
||||
const hasInitializedThings = useRef(false);
|
||||
const hasInitializedTrips = useRef(false);
|
||||
|
||||
// Body call API things (đang fix cứng)
|
||||
const payloadThings: Model.SearchThingBody = {
|
||||
@@ -55,6 +60,8 @@ export default function diary() {
|
||||
// Gọi API things
|
||||
const { getThings } = useThings();
|
||||
useEffect(() => {
|
||||
if (hasInitializedThings.current) return;
|
||||
hasInitializedThings.current = true;
|
||||
getThings(payloadThings);
|
||||
}, []);
|
||||
|
||||
@@ -79,10 +86,10 @@ export default function diary() {
|
||||
|
||||
const { tripsList, getTripsList, loading } = useTripsList();
|
||||
|
||||
// console.log("Payload trips:", payloadTrips);
|
||||
|
||||
// Gọi API trips lần đầu
|
||||
useEffect(() => {
|
||||
if (hasInitializedTrips.current) return;
|
||||
hasInitializedTrips.current = true;
|
||||
isInitialLoad.current = true;
|
||||
setAllTrips([]);
|
||||
setHasMore(true);
|
||||
@@ -157,7 +164,11 @@ export default function diary() {
|
||||
|
||||
// Hàm load more data khi scroll đến cuối
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isLoadingMore || !hasMore) {
|
||||
// Không load more nếu:
|
||||
// - Đang loading (ban đầu hoặc loadMore)
|
||||
// - Không còn data để load
|
||||
// - Chưa có data nào (tránh FlatList tự trigger khi list rỗng)
|
||||
if (isLoadingMore || loading || !hasMore || allTrips.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,7 +180,7 @@ export default function diary() {
|
||||
};
|
||||
setPayloadTrips(updatedPayload);
|
||||
getTripsList(updatedPayload);
|
||||
}, [isLoadingMore, hasMore, payloadTrips]);
|
||||
}, [isLoadingMore, loading, hasMore, allTrips.length, payloadTrips]);
|
||||
|
||||
// const handleTripPress = (tripId: string) => {
|
||||
// // TODO: Navigate to trip detail
|
||||
@@ -177,11 +188,16 @@ export default function diary() {
|
||||
// };
|
||||
|
||||
const handleViewTrip = (tripId: string) => {
|
||||
// Find the trip from allTrips and open modal in view mode
|
||||
// Navigate to trip detail page instead of opening modal
|
||||
const tripToView = allTrips.find((trip) => trip.id === tripId);
|
||||
if (tripToView) {
|
||||
setViewingTrip(tripToView);
|
||||
setShowAddTripModal(true);
|
||||
router.push({
|
||||
pathname: "/trip-detail",
|
||||
params: {
|
||||
tripId: tripToView.id,
|
||||
tripData: JSON.stringify(tripToView)
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -195,8 +211,13 @@ export default function diary() {
|
||||
};
|
||||
|
||||
const handleViewTeam = (tripId: string) => {
|
||||
console.log("View team:", tripId);
|
||||
// TODO: Navigate to team management
|
||||
const trip = allTrips.find((t) => t.id === tripId);
|
||||
if (trip) {
|
||||
router.push({
|
||||
pathname: "/trip-crew",
|
||||
params: { tripId: trip.id, tripName: trip.name || "" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTrip = (tripId: string) => {
|
||||
@@ -260,7 +281,13 @@ export default function diary() {
|
||||
onDelete={() => handleDeleteTrip(item.id)}
|
||||
/>
|
||||
),
|
||||
[handleViewTrip, handleEditTrip, handleViewTeam, handleSendTrip, handleDeleteTrip]
|
||||
[
|
||||
handleViewTrip,
|
||||
handleEditTrip,
|
||||
handleViewTeam,
|
||||
handleSendTrip,
|
||||
handleDeleteTrip,
|
||||
]
|
||||
);
|
||||
|
||||
// Key extractor cho FlatList
|
||||
@@ -301,10 +328,15 @@ export default function diary() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.safeArea, themedStyles.safeArea]} edges={["top"]}>
|
||||
<SafeAreaView
|
||||
style={[styles.safeArea, themedStyles.safeArea]}
|
||||
edges={["top"]}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<Text style={[styles.titleText, themedStyles.titleText]}>{t("diary.title")}</Text>
|
||||
<Text style={[styles.titleText, themedStyles.titleText]}>
|
||||
{t("diary.title")}
|
||||
</Text>
|
||||
|
||||
{/* Filter & Add Button Row */}
|
||||
<View style={styles.actionRow}>
|
||||
@@ -358,17 +390,16 @@ export default function diary() {
|
||||
onApply={handleApplyFilters}
|
||||
/>
|
||||
|
||||
{/* Add/Edit/View Trip Modal */}
|
||||
{/* Add/Edit Trip Modal */}
|
||||
<AddTripModal
|
||||
visible={showAddTripModal}
|
||||
onClose={() => {
|
||||
setShowAddTripModal(false);
|
||||
setEditingTrip(null);
|
||||
setViewingTrip(null);
|
||||
}}
|
||||
onSuccess={handleTripAddSuccess}
|
||||
mode={viewingTrip ? 'view' : editingTrip ? 'edit' : 'add'}
|
||||
tripData={viewingTrip || editingTrip || undefined}
|
||||
mode={editingTrip ? "edit" : "add"}
|
||||
tripData={editingTrip || undefined}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@@ -394,17 +425,10 @@ const styles = StyleSheet.create({
|
||||
}),
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 20,
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
countText: {
|
||||
@@ -421,7 +445,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
gap: 6,
|
||||
},
|
||||
|
||||
111
app/_layout.tsx
111
app/_layout.tsx
@@ -2,14 +2,14 @@ import {
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
ThemeProvider,
|
||||
Theme,
|
||||
} from "@react-navigation/native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import "react-native-reanimated";
|
||||
// import Toast from "react-native-toast-message";
|
||||
|
||||
// import { toastConfig } from "@/config";
|
||||
import { toastConfig } from "@/config";
|
||||
import { setRouterInstance } from "@/config/auth";
|
||||
import "@/global.css";
|
||||
@@ -18,50 +18,101 @@ import {
|
||||
ThemeProvider as AppThemeProvider,
|
||||
useThemeContext,
|
||||
} from "@/hooks/use-theme-context";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import Toast from "react-native-toast-message";
|
||||
import "../global.css";
|
||||
function AppContent() {
|
||||
const router = useRouter();
|
||||
const { colorScheme } = useThemeContext();
|
||||
console.log("Color Scheme: ", colorScheme);
|
||||
const { colorScheme, colors } = useThemeContext();
|
||||
|
||||
useEffect(() => {
|
||||
setRouterInstance(router);
|
||||
}, [router]);
|
||||
|
||||
// Create custom navigation theme that uses our app colors
|
||||
// This ensures the navigation container background matches our theme
|
||||
const navigationTheme: Theme = useMemo(() => {
|
||||
const baseTheme = colorScheme === "dark" ? DarkTheme : DefaultTheme;
|
||||
return {
|
||||
...baseTheme,
|
||||
colors: {
|
||||
...baseTheme.colors,
|
||||
background: colors.background,
|
||||
card: colors.card,
|
||||
text: colors.text,
|
||||
border: colors.border,
|
||||
primary: colors.primary,
|
||||
},
|
||||
};
|
||||
}, [colorScheme, colors]);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack
|
||||
screenOptions={{ headerShown: false }}
|
||||
initialRouteName="auth/login"
|
||||
>
|
||||
<Stack.Screen
|
||||
name="auth/login"
|
||||
options={{
|
||||
title: "Login",
|
||||
// Wrap entire app with View that has themed background
|
||||
// This prevents any white flash during screen transitions
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ThemeProvider value={navigationTheme}>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
// Set content background to match theme
|
||||
contentStyle: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
// Animation settings for smoother transitions
|
||||
animation: "slide_from_right",
|
||||
}}
|
||||
/>
|
||||
initialRouteName="auth/login"
|
||||
>
|
||||
<Stack.Screen
|
||||
name="auth/login"
|
||||
options={{
|
||||
title: "Login",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
title: "Home",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
title: "Home",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ presentation: "formSheet", title: "Modal" }}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
||||
</ThemeProvider>
|
||||
<Stack.Screen
|
||||
name="trip-detail"
|
||||
options={{
|
||||
title: "Trip Detail",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="trip-crew"
|
||||
options={{
|
||||
title: "Trip Crew",
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{ presentation: "formSheet", title: "Modal" }}
|
||||
/>
|
||||
</Stack>
|
||||
<StatusBar style={colorScheme === "dark" ? "light" : "dark"} />
|
||||
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
|
||||
</ThemeProvider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
|
||||
283
app/trip-crew.tsx
Normal file
283
app/trip-crew.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} 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 { queryTripCrew } from "@/controller/TripCrewController";
|
||||
import CrewList from "@/components/diary/TripCrewModal/CrewList";
|
||||
import AddEditCrewModal from "@/components/diary/TripCrewModal/AddEditCrewModal";
|
||||
|
||||
export default function TripCrewPage() {
|
||||
const { t } = useI18n();
|
||||
const { colors } = useThemeContext();
|
||||
const router = useRouter();
|
||||
const { tripId, tripName } = useLocalSearchParams<{ tripId: string; tripName?: string }>();
|
||||
|
||||
// State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [crews, setCrews] = useState<Model.TripCrews[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Add/Edit modal state
|
||||
const [showAddEditModal, setShowAddEditModal] = useState(false);
|
||||
const [editingCrew, setEditingCrew] = useState<Model.TripCrews | null>(null);
|
||||
|
||||
// Fetch crew data
|
||||
const fetchCrewData = useCallback(async () => {
|
||||
if (!tripId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await queryTripCrew(tripId);
|
||||
const data = response.data as any;
|
||||
|
||||
if (data?.trip_crews && Array.isArray(data.trip_crews)) {
|
||||
setCrews(data.trip_crews);
|
||||
} else if (Array.isArray(data)) {
|
||||
setCrews(data);
|
||||
} else {
|
||||
setCrews([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching crew:", err);
|
||||
setError(t("diary.crew.fetchError"));
|
||||
setCrews([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tripId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCrewData();
|
||||
}, [fetchCrewData]);
|
||||
|
||||
// Add crew handler
|
||||
const handleAddCrew = () => {
|
||||
setEditingCrew(null);
|
||||
setShowAddEditModal(true);
|
||||
};
|
||||
|
||||
// Edit crew handler
|
||||
const handleEditCrew = (crew: Model.TripCrews) => {
|
||||
setEditingCrew(crew);
|
||||
setShowAddEditModal(true);
|
||||
};
|
||||
|
||||
// Delete crew handler
|
||||
const handleDeleteCrew = (crew: Model.TripCrews) => {
|
||||
Alert.alert(
|
||||
t("diary.crew.deleteConfirmTitle"),
|
||||
t("diary.crew.deleteConfirmMessage", { name: crew.Person?.name || "" }),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
// TODO: Call delete API when available
|
||||
setCrews((prev) => prev.filter((c) => c.PersonalID !== crew.PersonalID));
|
||||
Alert.alert(t("common.success"), t("diary.crew.deleteSuccess"));
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Save crew handler
|
||||
const handleSaveCrew = async (formData: any) => {
|
||||
// TODO: Call API to add/edit crew when available
|
||||
await fetchCrewData();
|
||||
};
|
||||
|
||||
const themedStyles = {
|
||||
container: { backgroundColor: colors.background },
|
||||
header: { backgroundColor: colors.card, borderBottomColor: colors.separator },
|
||||
title: { color: colors.text },
|
||||
subtitle: { color: colors.textSecondary },
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, themedStyles.container]} edges={["top"]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, themedStyles.header]}>
|
||||
<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, themedStyles.title]}>
|
||||
{t("diary.crew.title")}
|
||||
</Text>
|
||||
{tripName && (
|
||||
<Text style={[styles.subtitle, themedStyles.subtitle]} numberOfLines={1}>
|
||||
{tripName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity onPress={handleAddCrew} style={styles.addButton}>
|
||||
<Ionicons name="add" size={24} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: colors.textSecondary }]}>
|
||||
{t("diary.crew.loading")}
|
||||
</Text>
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Ionicons name="alert-circle-outline" size={48} color={colors.error || "#FF3B30"} />
|
||||
<Text style={[styles.errorText, { color: colors.error || "#FF3B30" }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: colors.primary }]}
|
||||
onPress={fetchCrewData}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>{t("common.retry")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<CrewList crews={crews} onEdit={handleEditCrew} onDelete={handleDeleteCrew} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Footer - Crew count */}
|
||||
<View style={[styles.footer, { backgroundColor: colors.card, borderTopColor: colors.separator }]}>
|
||||
<View style={styles.countContainer}>
|
||||
<Ionicons name="people-outline" size={20} color={colors.primary} />
|
||||
<Text style={[styles.countText, { color: colors.text }]}>
|
||||
{t("diary.crew.totalMembers", { count: crews.length })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Add/Edit Crew Modal */}
|
||||
<AddEditCrewModal
|
||||
visible={showAddEditModal}
|
||||
onClose={() => {
|
||||
setShowAddEditModal(false);
|
||||
setEditingCrew(null);
|
||||
}}
|
||||
onSave={handleSaveCrew}
|
||||
mode={editingCrew ? "edit" : "add"}
|
||||
initialData={
|
||||
editingCrew
|
||||
? {
|
||||
personalId: editingCrew.PersonalID,
|
||||
name: editingCrew.Person?.name || "",
|
||||
phone: editingCrew.Person?.phone || "",
|
||||
email: editingCrew.Person?.email || "",
|
||||
address: editingCrew.Person?.address || "",
|
||||
role: editingCrew.role || "crew",
|
||||
note: editingCrew.note || "",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
backButton: {
|
||||
padding: 4,
|
||||
},
|
||||
addButton: {
|
||||
padding: 4,
|
||||
},
|
||||
headerTitles: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 14,
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
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" }),
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: "#FFFFFF",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 30,
|
||||
},
|
||||
countContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
},
|
||||
countText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
fontFamily: Platform.select({ ios: "System", android: "Roboto", default: "System" }),
|
||||
},
|
||||
});
|
||||
312
app/trip-detail.tsx
Normal file
312
app/trip-detail.tsx
Normal 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" }),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user