Khởi tạo ban đầu

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

92
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
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 { useI18n } from "@/hooks/use-i18n";
import { useColorScheme } from "@/hooks/use-theme-context";
import { useEffect, useRef } from "react";
export default function TabLayout() {
const colorScheme = useColorScheme();
const segments = useSegments() as string[];
const prev = useRef<string | null>(null);
const currentSegment = segments[1] ?? segments[segments.length - 1] ?? null;
const { t, locale } = useI18n();
useEffect(() => {
if (prev.current !== currentSegment) {
// console.log("Tab changed ->", { from: prev.current, to: currentSegment });
// TODO: xử lý khi chuyển tab ở đây
if (prev.current === "(tabs)" && currentSegment !== "(tabs)") {
// stopEvents();
// console.log("Stop events");
} else if (prev.current !== "(tabs)" && currentSegment === "(tabs)") {
// we came back into the tabs group — restart polling
// startEvents();
// console.log("start events");
}
prev.current = currentSegment;
}
}, [currentSegment]);
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
headerShown: false,
tabBarButton: HapticTab,
}}
>
<Tabs.Screen
name="index"
options={{
title: t("navigation.home"),
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="map.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="tripInfo"
options={{
title: t("navigation.trip"),
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="ferry.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="diary"
options={{
title: t("navigation.diary"),
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="book.closed.fill" color={color} />
),
}}
/>
<Tabs.Screen
name="sensor"
options={{
title: t("navigation.sensor"),
tabBarIcon: ({ color }) => (
<IconSymbol
size={28}
name="dot.radiowaves.left.and.right"
color={color}
/>
),
}}
/>
<Tabs.Screen
name="setting"
options={{
title: t("navigation.setting"),
tabBarIcon: ({ color }) => (
<IconSymbol size={28} name="gear" color={color} />
),
}}
/>
</Tabs>
);
}

47
app/(tabs)/diary.tsx Normal file
View File

@@ -0,0 +1,47 @@
import CreateOrUpdateHaulModal from "@/components/tripInfo/modal/CreateOrUpdateHaulModal";
import { useState } from "react";
import { Button, Platform, StyleSheet, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function Warning() {
const [isShowModal, setIsShowModal] = useState(false);
return (
<SafeAreaView style={{ flex: 1 }}>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
},
container: {
alignItems: "center",
padding: 15,
},
titleText: {
fontSize: 32,
fontWeight: "700",
lineHeight: 40,
marginBottom: 10,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
button: {
backgroundColor: "#007AFF",
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 8,
marginTop: 20,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
});

461
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,461 @@
import type { PolygonWithLabelProps } from "@/components/map/PolygonWithLabel";
import type { PolylineWithLabelProps } from "@/components/map/PolylineWithLabel";
import { IOS_PLATFORM, LIGHT_THEME } from "@/constants";
import { usePlatform } from "@/hooks/use-platform";
import { useThemeContext } from "@/hooks/use-theme-context";
import { useRef, useState } from "react";
import { Animated, StyleSheet, View } from "react-native";
import MapView from "react-native-maps";
export default function HomeScreen() {
const [gpsData, setGpsData] = useState<Model.GPSResponse | null>(null);
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
const [entityData, setEntityData] = useState<
Model.TransformedEntity[] | null
>(null);
const [banzoneData, setBanzoneData] = useState<Model.Zone[] | null>(null);
const [trackPointsData, setTrackPointsData] = useState<
Model.ShipTrackPoint[] | null
>(null);
const [circleRadius, setCircleRadius] = useState(100);
const [zoomLevel, setZoomLevel] = useState(10);
const [isFirstLoad, setIsFirstLoad] = useState(true);
const [polylineCoordinates, setPolylineCoordinates] = useState<
PolylineWithLabelProps[]
>([]);
const [polygonCoordinates, setPolygonCoordinates] = useState<
PolygonWithLabelProps[]
>([]);
const platform = usePlatform();
const theme = useThemeContext().colorScheme;
const scale = useRef(new Animated.Value(0)).current;
const opacity = useRef(new Animated.Value(1)).current;
// useEffect(() => {
// getGpsEventBus();
// getAlarmEventBus();
// getEntitiesEventBus();
// getBanzonesEventBus();
// getTrackPointsEventBus();
// const queryGpsData = (gpsData: Model.GPSResponse) => {
// if (gpsData) {
// // console.log("GPS Data: ", gpsData);
// setGpsData(gpsData);
// } else {
// setGpsData(null);
// setPolygonCoordinates([]);
// setPolylineCoordinates([]);
// }
// };
// const queryAlarmData = (alarmData: Model.AlarmResponse) => {
// // console.log("Alarm Data: ", alarmData.alarms.length);
// setAlarmData(alarmData);
// };
// const queryEntityData = (entityData: Model.TransformedEntity[]) => {
// // console.log("Entities Length Data: ", entityData.length);
// setEntityData(entityData);
// };
// const queryBanzonesData = (banzoneData: Model.Zone[]) => {
// // console.log("Banzone Data: ", banzoneData.length);
// setBanzoneData(banzoneData);
// };
// const queryTrackPointsData = (TrackPointsData: Model.ShipTrackPoint[]) => {
// // console.log("TrackPoints Data: ", TrackPointsData.length);
// if (TrackPointsData && TrackPointsData.length > 0) {
// setTrackPointsData(TrackPointsData);
// } else {
// setTrackPointsData([]);
// }
// };
// eventBus.on(EVENT_GPS_DATA, queryGpsData);
// // console.log("Registering event handlers in HomeScreen");
// eventBus.on(EVENT_GPS_DATA, queryGpsData);
// // console.log("Subscribed to EVENT_GPS_DATA");
// eventBus.on(EVENT_ALARM_DATA, queryAlarmData);
// // console.log("Subscribed to EVENT_ALARM_DATA");
// eventBus.on(EVENT_ENTITY_DATA, queryEntityData);
// // console.log("Subscribed to EVENT_ENTITY_DATA");
// eventBus.on(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
// // console.log("Subscribed to EVENT_TRACK_POINTS_DATA");
// eventBus.once(EVENT_BANZONE_DATA, queryBanzonesData);
// // console.log("Subscribed once to EVENT_BANZONE_DATA");
// return () => {
// // console.log("Unregistering event handlers in HomeScreen");
// eventBus.off(EVENT_GPS_DATA, queryGpsData);
// // console.log("Unsubscribed EVENT_GPS_DATA");
// eventBus.off(EVENT_ALARM_DATA, queryAlarmData);
// // console.log("Unsubscribed EVENT_ALARM_DATA");
// eventBus.off(EVENT_ENTITY_DATA, queryEntityData);
// // console.log("Unsubscribed EVENT_ENTITY_DATA");
// eventBus.off(EVENT_TRACK_POINTS_DATA, queryTrackPointsData);
// // console.log("Unsubscribed EVENT_TRACK_POINTS_DATA");
// };
// }, []);
// useEffect(() => {
// setPolylineCoordinates([]);
// setPolygonCoordinates([]);
// if (!entityData) return;
// if (!banzoneData) return;
// for (const entity of entityData) {
// if (entity.id !== ENTITY.ZONE_ALARM_LIST) {
// continue;
// }
// let zones: any[] = [];
// try {
// zones = entity.valueString ? JSON.parse(entity.valueString) : [];
// } catch (parseError) {
// console.error("Error parsing zone list:", parseError);
// continue;
// }
// // Nếu danh sách zone rỗng, clear tất cả
// if (zones.length === 0) {
// setPolylineCoordinates([]);
// setPolygonCoordinates([]);
// return;
// }
// let polylines: PolylineWithLabelProps[] = [];
// let polygons: PolygonWithLabelProps[] = [];
// for (const zone of zones) {
// // console.log("Zone Data: ", zone);
// const geom = banzoneData.find((b) => b.id === zone.zone_id);
// if (!geom) {
// continue;
// }
// const { geom_type, geom_lines, geom_poly } = geom.geom || {};
// if (typeof geom_type !== "number") {
// continue;
// }
// if (geom_type === 2) {
// // if(oldEntityData.find(e => e.id === ))
// // foundPolyline = true;
// const coordinates = convertWKTLineStringToLatLngArray(
// geom_lines || ""
// );
// if (coordinates.length > 0) {
// polylines.push({
// coordinates: coordinates.map((coord) => ({
// latitude: coord[0],
// longitude: coord[1],
// })),
// label: zone?.zone_name ?? "",
// content: zone?.message ?? "",
// });
// } else {
// console.log("Không tìm thấy polyline trong alarm");
// }
// } else if (geom_type === 1) {
// // foundPolygon = true;
// const coordinates = convertWKTtoLatLngString(geom_poly || "");
// if (coordinates.length > 0) {
// // console.log("Polygon Coordinate: ", coordinates);
// const zonePolygons = coordinates.map((polygon) => ({
// coordinates: polygon.map((coord) => ({
// latitude: coord[0],
// longitude: coord[1],
// })),
// label: zone?.zone_name ?? "",
// content: zone?.message ?? "",
// }));
// polygons.push(...zonePolygons);
// } else {
// console.log("Không tìm thấy polygon trong alarm");
// }
// }
// }
// setPolylineCoordinates(polylines);
// setPolygonCoordinates(polygons);
// }
// }, [banzoneData, entityData]);
// Hàm tính radius cố định khi zoom change
const calculateRadiusFromZoom = (zoom: number) => {
const baseZoom = 10;
const baseRadius = 100;
const zoomDifference = baseZoom - zoom;
const calculatedRadius = baseRadius * Math.pow(2, zoomDifference);
// console.log("Caculate Radius: ", calculatedRadius);
return Math.max(calculatedRadius, 50);
};
// Xử lý khi region (zoom) thay đổi
const handleRegionChangeComplete = (newRegion: any) => {
// Tính zoom level từ latitudeDelta
// zoom = log2(360 / (latitudeDelta * 2)) + 8
const zoom = Math.round(Math.log2(360 / (newRegion.latitudeDelta * 2)) + 8);
const newRadius = calculateRadiusFromZoom(zoom);
setCircleRadius(newRadius);
setZoomLevel(zoom);
// console.log("Zoom level:", zoom, "Circle radius:", newRadius);
};
// Tính toán region để focus vào marker của tàu (chỉ lần đầu tiên)
const getMapRegion = () => {
if (!isFirstLoad) {
// Sau lần đầu, return undefined để không force region
return undefined;
}
if (!gpsData) {
return {
latitude: 15.70581,
longitude: 116.152685,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
};
}
return {
latitude: gpsData.lat,
longitude: gpsData.lon,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
};
};
const handleMapReady = () => {
setTimeout(() => {
setIsFirstLoad(false);
}, 2000);
};
// useEffect(() => {
// if (alarmData?.level === 3) {
// const loop = Animated.loop(
// Animated.sequence([
// Animated.parallel([
// Animated.timing(scale, {
// toValue: 3, // nở to 3 lần
// duration: 1500,
// useNativeDriver: true,
// }),
// Animated.timing(opacity, {
// toValue: 0, // mờ dần
// duration: 1500,
// useNativeDriver: true,
// }),
// ]),
// Animated.parallel([
// Animated.timing(scale, {
// toValue: 0,
// duration: 0,
// useNativeDriver: true,
// }),
// Animated.timing(opacity, {
// toValue: 1,
// duration: 0,
// useNativeDriver: true,
// }),
// ]),
// ])
// );
// loop.start();
// return () => loop.stop();
// }
// }, [alarmData?.level, scale, opacity]);
return (
<View
// edges={["top"]}
style={styles.container}
>
<MapView
onMapReady={handleMapReady}
onRegionChangeComplete={handleRegionChangeComplete}
style={styles.map}
// initialRegion={getMapRegion()}
region={getMapRegion()}
userInterfaceStyle={theme === LIGHT_THEME ? "light" : "dark"}
showsBuildings={false}
showsIndoors={false}
loadingEnabled={true}
mapType={platform === IOS_PLATFORM ? "mutedStandard" : "standard"}
rotateEnabled={false}
>
{/* {trackPointsData &&
trackPointsData.length > 0 &&
trackPointsData.map((point, index) => {
// console.log(`Rendering circle ${index}:`, point);
return (
<Circle
key={`circle-${index}`}
center={{
latitude: point.lat,
longitude: point.lon,
}}
// zIndex={50}
// radius={platform === IOS_PLATFORM ? 200 : 50}
radius={circleRadius}
strokeColor="rgba(16, 85, 201, 0.7)"
fillColor="rgba(16, 85, 201, 0.7)"
strokeWidth={2}
/>
);
})}
{polylineCoordinates.length > 0 && (
<>
{polylineCoordinates.map((polyline, index) => (
<PolylineWithLabel
key={`polyline-${index}-${gpsData?.lat || 0}-${
gpsData?.lon || 0
}`}
coordinates={polyline.coordinates}
label={polyline.label}
content={polyline.content}
strokeColor="#FF5733"
strokeWidth={4}
showDistance={false}
// zIndex={50}
/>
))}
</>
)}
{polygonCoordinates.length > 0 && (
<>
{polygonCoordinates.map((polygon, index) => {
return (
<PolygonWithLabel
key={`polygon-${index}-${gpsData?.lat || 0}-${
gpsData?.lon || 0
}`}
coordinates={polygon.coordinates}
label={polygon.label}
content={polygon.content}
fillColor="rgba(16, 85, 201, 0.6)"
strokeColor="rgba(16, 85, 201, 0.8)"
strokeWidth={2}
// zIndex={50}
zoomLevel={zoomLevel}
/>
);
})}
</>
)} */}
{/* {gpsData !== null && (
<Marker
key={
platform === IOS_PLATFORM
? `${gpsData.lat}-${gpsData.lon}`
: "gps-data"
}
coordinate={{
latitude: gpsData.lat,
longitude: gpsData.lon,
}}
zIndex={20}
anchor={
platform === IOS_PLATFORM
? { x: 0.5, y: 0.5 }
: { x: 0.6, y: 0.4 }
}
tracksViewChanges={platform === IOS_PLATFORM ? true : undefined}
>
<View className="w-8 h-8 items-center justify-center">
<View style={styles.pingContainer}>
{alarmData?.level === 3 && (
<Animated.View
style={[
styles.pingCircle,
{
transform: [{ scale }],
opacity,
},
]}
/>
)}
<RNImage
source={(() => {
const icon = getShipIcon(
alarmData?.level || 0,
gpsData.fishing
);
// console.log("Ship icon:", icon, "for level:", alarmData?.level, "fishing:", gpsData.fishing);
return typeof icon === "string" ? { uri: icon } : icon;
})()}
style={{
width: 32,
height: 32,
transform: [
{
rotate: `${
typeof gpsData.h === "number" && !isNaN(gpsData.h)
? gpsData.h
: 0
}deg`,
},
],
}}
/>
</View>
</View>
</Marker>
)} */}
</MapView>
{/* <View className="absolute top-14 right-2 shadow-md">
<SosButton />
</View>
<GPSInfoPanel gpsData={gpsData!} /> */}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
map: {
flex: 1,
},
button: {
// display: "none",
position: "absolute",
top: 50,
right: 20,
backgroundColor: "#007AFF",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 8,
elevation: 5,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
buttonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
},
pingContainer: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
overflow: "visible",
},
pingCircle: {
position: "absolute",
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ED3F27",
},
centerDot: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "#0096FF",
},
});

129
app/(tabs)/sensor.tsx Normal file
View File

@@ -0,0 +1,129 @@
import ScanQRCode from "@/components/ScanQRCode";
import Select from "@/components/Select";
import { useState } from "react";
import {
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function Sensor() {
const [scanModalVisible, setScanModalVisible] = useState(false);
const [scannedData, setScannedData] = useState<string | null>(null);
const handleQRCodeScanned = (data: string) => {
setScannedData(data);
// Alert.alert("QR Code Scanned", `Result: ${data}`);
};
const handleScanPress = () => {
setScanModalVisible(true);
};
const [selectedValue, setSelectedValue] = useState<
string | number | undefined
>(undefined);
const options = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Cherry", value: "cherry", disabled: true },
];
return (
<SafeAreaView style={{ flex: 1 }}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.container}>
<Text style={styles.titleText}>Cảm biến trên tàu</Text>
<Select
style={{ width: "80%", marginBottom: 20 }}
options={options}
value={selectedValue}
onChange={(val) => {
setSelectedValue(val);
console.log("Value: ", val);
}}
placeholder="Select a fruit"
allowClear
/>
<Pressable style={styles.scanButton} onPress={handleScanPress}>
<Text style={styles.scanButtonText}>📱 Scan QR Code</Text>
</Pressable>
{scannedData && (
<View style={styles.resultContainer}>
<Text style={styles.resultLabel}>Last Scanned:</Text>
<Text style={styles.resultText}>{scannedData}</Text>
</View>
)}
</View>
</ScrollView>
<ScanQRCode
visible={scanModalVisible}
onClose={() => setScanModalVisible(false)}
onScanned={handleQRCodeScanned}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
},
container: {
alignItems: "center",
padding: 15,
},
titleText: {
fontSize: 32,
fontWeight: "700",
lineHeight: 40,
marginBottom: 30,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
scanButton: {
backgroundColor: "#007AFF",
paddingVertical: 14,
paddingHorizontal: 30,
borderRadius: 10,
marginVertical: 15,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
scanButtonText: {
color: "#fff",
fontSize: 16,
fontWeight: "600",
textAlign: "center",
},
resultContainer: {
marginTop: 30,
padding: 15,
backgroundColor: "#f0f0f0",
borderRadius: 10,
minWidth: "80%",
},
resultLabel: {
fontSize: 14,
fontWeight: "600",
color: "#666",
marginBottom: 8,
},
resultText: {
fontSize: 14,
color: "#333",
fontFamily: "Menlo",
fontWeight: "500",
},
});

153
app/(tabs)/setting.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { ScrollView, StyleSheet, View } from "react-native";
import EnIcon from "@/assets/icons/en_icon.png";
import VnIcon from "@/assets/icons/vi_icon.png";
import RotateSwitch from "@/components/rotate-switch";
import { ThemeToggle } from "@/components/theme-toggle";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { DOMAIN, TOKEN } from "@/constants";
import { useAppTheme } from "@/hooks/use-app-theme";
import { useI18n } from "@/hooks/use-i18n";
import { removeStorageItem } from "@/utils/storage";
import { SafeAreaView } from "react-native-safe-area-context";
type Todo = {
userId: number;
id: number;
title: string;
completed: boolean;
};
export default function SettingScreen() {
const router = useRouter();
const [data, setData] = useState<Todo | null>(null);
const { t, locale, setLocale } = useI18n();
const { colors } = useAppTheme();
const [isEnabled, setIsEnabled] = useState(locale === "vi");
// Sync isEnabled state khi locale thay đổi
useEffect(() => {
setIsEnabled(locale === "vi");
}, [locale]);
const toggleSwitch = async () => {
const newLocale = isEnabled ? "en" : "vi";
await setLocale(newLocale);
};
return (
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
<ThemedView style={styles.container}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<ThemedText type="title" style={styles.title}>
{t("navigation.setting")}
</ThemedText>
{/* Theme Toggle Section */}
<ThemeToggle style={styles.themeSection} />
{/* Language Section */}
<View
style={[
styles.settingItem,
{
backgroundColor: colors.surface,
borderColor: colors.border,
},
]}
>
<ThemedText type="default">{t("common.language")}</ThemedText>
<RotateSwitch
initialValue={isEnabled}
onChange={toggleSwitch}
size="sm"
offImage={EnIcon}
onImage={VnIcon}
/>
</View>
{/* Logout Button */}
<ThemedView
style={[styles.button, { backgroundColor: colors.primary }]}
onTouchEnd={async () => {
await removeStorageItem(TOKEN);
await removeStorageItem(DOMAIN);
router.navigate("/auth/login");
}}
>
<ThemedText type="defaultSemiBold" style={styles.buttonText}>
{t("auth.logout")}
</ThemedText>
</ThemedView>
{data && (
<ThemedView
style={[styles.debugSection, { backgroundColor: colors.surface }]}
>
<ThemedText type="default">{data.title}</ThemedText>
<ThemedText type="default">{data.completed}</ThemedText>
<ThemedText type="default">{data.id}</ThemedText>
</ThemedView>
)}
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
paddingBottom: 5,
},
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: 20,
gap: 16,
},
title: {
textAlign: "center",
marginBottom: 20,
},
themeSection: {
marginBottom: 8,
},
settingItem: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 16,
borderRadius: 12,
borderWidth: 1,
},
button: {
marginTop: 20,
paddingVertical: 14,
paddingHorizontal: 20,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
buttonText: {
color: "#fff",
fontSize: 16,
},
debugSection: {
marginTop: 20,
padding: 16,
borderRadius: 12,
gap: 8,
},
});

83
app/(tabs)/tripInfo.tsx Normal file
View File

@@ -0,0 +1,83 @@
import ButtonCancelTrip from "@/components/ButtonCancelTrip";
import ButtonCreateNewHaulOrTrip from "@/components/ButtonCreateNewHaulOrTrip";
import ButtonEndTrip from "@/components/ButtonEndTrip";
import CrewListTable from "@/components/tripInfo/CrewListTable";
import FishingToolsTable from "@/components/tripInfo/FishingToolsList";
import NetListTable from "@/components/tripInfo/NetListTable";
import TripCostTable from "@/components/tripInfo/TripCostTable";
import { useI18n } from "@/hooks/use-i18n";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
export default function TripInfoScreen() {
const { t } = useI18n();
const { colors } = useThemeContext();
return (
<SafeAreaView style={styles.safeArea} edges={["top", "left", "right"]}>
<View style={styles.header}>
<Text style={[styles.titleText, { color: colors.text }]}>
{t("trip.infoTrip")}
</Text>
<View style={styles.buttonWrapper}>
<ButtonCreateNewHaulOrTrip />
</View>
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.container}>
<TripCostTable />
<FishingToolsTable />
<CrewListTable />
<NetListTable />
<View style={styles.buttonRow}>
<ButtonCancelTrip />
<ButtonEndTrip />
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
paddingBottom: 5,
},
scrollContent: {
flexGrow: 1,
},
header: {
width: "100%",
paddingHorizontal: 15,
paddingTop: 15,
paddingBottom: 10,
alignItems: "center",
},
buttonWrapper: {
width: "100%",
flexDirection: "row",
justifyContent: "flex-end",
},
container: {
alignItems: "center",
paddingHorizontal: 15,
},
buttonRow: {
flexDirection: "row",
gap: 10,
marginTop: 15,
marginBottom: 15,
},
titleText: {
fontSize: 32,
fontWeight: "700",
lineHeight: 40,
paddingBottom: 10,
fontFamily: Platform.select({
ios: "System",
android: "Roboto",
default: "System",
}),
},
});

70
app/_layout.tsx Normal file
View File

@@ -0,0 +1,70 @@
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { Stack, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { useEffect } from "react";
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";
import { I18nProvider } from "@/hooks/use-i18n";
import { ThemeProvider as AppThemeProvider, useThemeContext } from "@/hooks/use-theme-context";
import Toast from "react-native-toast-message";
import "../global.css";
function AppContent() {
const router = useRouter();
const { colorScheme } = useThemeContext();
console.log("Color Scheme: ", colorScheme);
useEffect(() => {
setRouterInstance(router);
}, [router]);
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack
screenOptions={{ headerShown: false }}
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="modal"
options={{ presentation: "formSheet", title: "Modal" }}
/>
</Stack>
<StatusBar style="auto" />
<Toast config={toastConfig} visibilityTime={2000} topOffset={60} />
</ThemeProvider>
);
}
export default function RootLayout() {
return (
<I18nProvider>
<AppThemeProvider>
<AppContent />
</AppThemeProvider>
</I18nProvider>
);
}

410
app/auth/login.tsx Normal file
View File

@@ -0,0 +1,410 @@
import EnIcon from "@/assets/icons/en_icon.png";
import VnIcon from "@/assets/icons/vi_icon.png";
import RotateSwitch from "@/components/rotate-switch";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import SliceSwitch from "@/components/ui/slice-switch";
import { TOKEN } from "@/constants";
import { Colors } from "@/constants/theme";
import { queryLogin } from "@/controller/AuthController";
import { useI18n } from "@/hooks/use-i18n";
import {
ColorScheme as ThemeColorScheme,
useTheme,
useThemeContext,
} from "@/hooks/use-theme-context";
import { showErrorToast } from "@/services/toast_service";
import {
getStorageItem,
removeStorageItem,
setStorageItem,
} from "@/utils/storage";
import { parseJwtToken } from "@/utils/token";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Image,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
export default function LoginScreen() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { t, setLocale, locale } = useI18n();
const { colors, colorScheme } = useTheme();
const { setThemeMode } = useThemeContext();
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 () => {
const token = await getStorageItem(TOKEN);
if (!token) {
return;
}
const parsed = parseJwtToken(token);
const { exp } = parsed;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
if (exp - now < oneHour) {
await removeStorageItem(TOKEN);
} else {
console.log("Token còn hạn");
router.replace("/(tabs)");
}
}, [router]);
useEffect(() => {
setIsVNLang(locale === "vi");
}, [locale]);
useEffect(() => {
checkLogin();
}, [checkLogin]);
const handleLogin = async (creds?: {
username: string;
password: string;
}) => {
const user = creds?.username ?? username;
const pass = creds?.password ?? password;
// Validate input
if (!user?.trim() || !pass?.trim()) {
showErrorToast("Vui lòng nhập tài khoản và mật khẩu");
return;
}
setLoading(true);
try {
const body: Model.LoginRequestBody = {
guid: "9812739812738213",
email: user,
password: pass,
};
const response = await queryLogin(body);
// Nếu thành công, lưu token và chuyển sang (tabs)
console.log("Login thành công với data:", response.data);
if (response?.data.token) {
// Lưu token vào storage nếu cần (thêm logic này sau)
console.log("Login Token ");
await setStorageItem(TOKEN, response.data.token);
// console.log("Token:", response.data.token);
router.replace("/(tabs)");
}
} catch (error) {
showErrorToast(
error instanceof Error ? error.message : "Đăng nhập thất bại"
);
} finally {
setLoading(false);
}
};
const handleSwitchLanguage = (isVN: boolean) => {
if (isVN) {
setLocale("vi");
} else {
setLocale("en");
}
setIsVNLang(isVN);
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<ScrollView
style={{ flex: 1, backgroundColor: colors.background }}
contentContainerStyle={styles.scrollContainer}
>
<ThemedView style={styles.container}>
{/* Header */}
<View style={styles.headerContainer}>
{/* Logo */}
<Image
source={require("@/assets/images/logo.png")}
style={styles.logo}
resizeMode="contain"
/>
<ThemedText type="title" style={styles.title}>
{t("common.app_name")}
</ThemedText>
</View>
{/* Form */}
<View style={styles.formContainer}>
{/* Username Input */}
<View style={styles.inputGroup}>
<ThemedText style={styles.label}>{t("auth.username")}</ThemedText>
<TextInput
style={styles.input}
placeholder={t("auth.username_placeholder")}
placeholderTextColor={placeholderColor}
value={username}
onChangeText={setUsername}
editable={!loading}
autoCapitalize="none"
/>
</View>
{/* Password Input */}
<View style={styles.inputGroup}>
<ThemedText style={styles.label}>{t("auth.password")}</ThemedText>
<View className="relative">
<TextInput
style={styles.input}
placeholder={t("auth.password_placeholder")}
placeholderTextColor={placeholderColor}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
editable={!loading}
autoCapitalize="none"
/>
{/* Position absolute with top:0 and bottom:0 and justifyContent:center
ensures the icon remains vertically centered inside the input */}
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={{
position: "absolute",
right: 12,
top: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
padding: 4,
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons
name={showPassword ? "eye-off" : "eye"}
size={22}
color={colors.icon}
/>
</TouchableOpacity>
</View>
</View>
{/* Login Button (3/4) + QR Scan (1/4) */}
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
}}
>
<TouchableOpacity
style={[
styles.loginButton,
loading && styles.loginButtonDisabled,
{ flex: 5, marginTop: 0 },
]}
onPress={() => handleLogin()}
disabled={loading}
>
{loading ? (
<ActivityIndicator color={buttonTextColor} size="small" />
) : (
<Text
style={[styles.loginButtonText, { color: buttonTextColor }]}
>
{t("auth.login")}
</Text>
)}
</TouchableOpacity>
</View>
{/* Language Switcher */}
<View style={styles.languageSwitcherContainer}>
<RotateSwitch
initialValue={isVNLang}
onChange={handleSwitchLanguage}
size="sm"
offImage={EnIcon}
onImage={VnIcon}
/>
<SliceSwitch
size="sm"
leftIcon="moon"
leftIconColor={
colorScheme === "dark" ? colors.background : colors.surface
}
rightIcon="sunny"
rightIconColor={
colorScheme === "dark" ? colors.warning : "orange"
}
activeBackgroundColor={colors.text}
inactiveBackgroundColor={colors.surface}
inactiveOverlayColor={colors.textSecondary}
activeOverlayColor={colors.background}
value={colorScheme === "light"}
onChange={(val) => {
setThemeMode(val ? "light" : "dark");
}}
/>
</View>
{/* Copyright */}
<View style={styles.copyrightContainer}>
<ThemedText style={styles.copyrightText}>
© {new Date().getFullYear()} - {t("common.footer_text")}
</ThemedText>
</View>
</View>
</ThemedView>
</ScrollView>
</KeyboardAvoidingView>
);
}
const createStyles = (colors: typeof Colors.light, scheme: ThemeColorScheme) =>
StyleSheet.create({
scrollContainer: {
flexGrow: 1,
justifyContent: "center",
backgroundColor: colors.background,
},
container: {
flex: 1,
paddingHorizontal: 20,
justifyContent: "center",
},
headerContainer: {
marginBottom: 40,
alignItems: "center",
},
logo: {
width: 120,
height: 120,
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
opacity: 0.7,
},
formContainer: {
gap: 16,
},
inputGroup: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: "600",
},
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 16,
backgroundColor: colors.surface,
color: colors.text,
},
loginButton: {
backgroundColor: colors.primary,
paddingVertical: 14,
borderRadius: 8,
alignItems: "center",
marginTop: 16,
},
loginButtonDisabled: {
opacity: 0.6,
},
loginButtonText: {
fontSize: 16,
fontWeight: "600",
},
footerContainer: {
marginTop: 16,
alignItems: "center",
},
footerText: {
fontSize: 14,
},
linkText: {
color: colors.primary,
fontWeight: "600",
},
copyrightContainer: {
marginTop: 20,
alignItems: "center",
},
copyrightText: {
fontSize: 12,
opacity: 0.6,
textAlign: "center",
color: colors.textSecondary,
},
languageSwitcherContainer: {
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
marginTop: 24,
gap: 20,
},
languageSwitcherLabel: {
fontSize: 12,
fontWeight: "600",
textAlign: "center",
opacity: 0.8,
},
languageButtonsContainer: {
flexDirection: "row",
gap: 10,
},
languageButton: {
flex: 1,
paddingVertical: 10,
paddingHorizontal: 12,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
alignItems: "center",
backgroundColor: colors.surface,
},
languageButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
languageButtonText: {
fontSize: 14,
fontWeight: "500",
color: colors.textSecondary,
},
languageButtonTextActive: {
color: scheme === "dark" ? colors.text : colors.surface,
fontWeight: "600",
},
});

5
app/index.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Redirect } from "expo-router";
export default function Index() {
return <Redirect href="/auth/login" />;
}

29
app/modal.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});