thêm giao diện map và cập nhật nativewind

This commit is contained in:
Tran Anh Tuan
2025-10-30 17:55:25 +07:00
parent d717a360b7
commit f9ca9542c4
31 changed files with 1068 additions and 65 deletions

View File

@@ -23,7 +23,8 @@
}, },
"web": { "web": {
"output": "static", "output": "static",
"favicon": "./assets/images/favicon.png" "favicon": "./assets/images/favicon.png",
"bundler": "metro"
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",

View File

@@ -1,32 +1,125 @@
import { showToastError } from "@/config"; import { showToastError } from "@/config";
import { fetchGpsData } from "@/controller/DeviceController"; import {
queryAlarm,
queryGpsData,
queryTrackPoints,
} from "@/controller/DeviceController";
import { getShipIcon } from "@/services/map_service";
import { Image as ExpoImage } from "expo-image";
import { useState } from "react"; import { useState } from "react";
import { StyleSheet, Text, TouchableOpacity } from "react-native"; import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import MapView, { Marker } from "react-native-maps"; import MapView, { Circle, Marker } from "react-native-maps";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
export default function HomeScreen() { export default function HomeScreen() {
const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null); const [gpsData, setGpsData] = useState<Model.GPSResonse | null>(null);
const [alarmData, setAlarmData] = useState<Model.AlarmResponse | null>(null);
// useEffect(() => { const [trackPoints, setTrackPoints] = useState<Model.ShipTrackPoint[] | null>(
// getGpsData(); null
// }, []); );
const getGpsData = async () => { const getGpsData = async () => {
try { try {
const response = await fetchGpsData(); const response = await queryGpsData();
console.log("GpsData: ", response.data); console.log("GpsData: ", response.data);
console.log(
"Heading value:",
response.data?.h,
"Type:",
typeof response.data?.h
);
setGpsData(response.data); setGpsData(response.data);
} catch (error) { } catch (error) {
console.error("Error fetching GPS data:", error);
showToastError("Lỗi", "Không thể lấy dữ liệu GPS"); showToastError("Lỗi", "Không thể lấy dữ liệu GPS");
} }
}; };
const getAlarmData = async () => {
try {
const response = await queryAlarm();
console.log("AlarmData: ", response.data);
setAlarmData(response.data);
} catch (error) {
console.error("Error fetching Alarm Data: ", error);
showToastError("Lỗi", "Không thể lấy dữ liệu báo động");
}
};
const getShipTrackPoints = async () => {
try {
const response = await queryTrackPoints();
console.log(
"TrackPoints Data: ",
response.data[response.data.length - 1]
);
setTrackPoints(response.data);
} catch (error) {
console.error("Error fetching TrackPoints Data: ", error);
showToastError("Lỗi", "Không thể lấy lịch sử di chuyển");
}
};
const handleMapReady = () => {
console.log("Map loaded successfully!");
getGpsData();
getAlarmData();
getShipTrackPoints();
};
// Tính toán region để bao phủ cả GPS và track points
const getMapRegion = () => {
if (!gpsData && (!trackPoints || trackPoints.length === 0)) {
return {
latitude: 15.70581,
longitude: 116.152685,
latitudeDelta: 2,
longitudeDelta: 2,
};
}
let minLat = gpsData?.lat ?? 90;
let maxLat = gpsData?.lat ?? -90;
let minLon = gpsData?.lon ?? 180;
let maxLon = gpsData?.lon ?? -180;
// Bao gồm track points
if (trackPoints) {
trackPoints.forEach((point) => {
minLat = Math.min(minLat, point.lat);
maxLat = Math.max(maxLat, point.lat);
minLon = Math.min(minLon, point.lon);
maxLon = Math.max(maxLon, point.lon);
});
}
const latDelta = Math.max(maxLat - minLat, 0.01) * 1.2; // Padding 20%
const lonDelta = Math.max(maxLon - minLon, 0.01) * 1.2;
console.log("Map region:", {
minLat,
maxLat,
minLon,
maxLon,
latDelta,
lonDelta,
});
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2,
latitudeDelta: latDelta,
longitudeDelta: lonDelta,
};
};
return ( return (
<SafeAreaProvider style={styles.container}> <SafeAreaProvider style={styles.container}>
<MapView <MapView
onMapReady={handleMapReady}
onPoiClick={(point) => {
console.log("Poi clicked: ", point.nativeEvent);
}}
style={styles.map} style={styles.map}
initialRegion={{ initialRegion={{
latitude: 15.70581, latitude: 15.70581,
@@ -34,20 +127,65 @@ export default function HomeScreen() {
latitudeDelta: 2, latitudeDelta: 2,
longitudeDelta: 2, longitudeDelta: 2,
}} }}
mapType="none" region={getMapRegion()}
// userInterfaceStyle="dark"
showsBuildings={false}
showsIndoors={false}
loadingEnabled={true}
mapType="standard"
> >
{trackPoints &&
trackPoints.length > 0 &&
trackPoints.map((point, index) => {
// console.log(`Rendering circle ${index}:`, point);
return (
<Circle
key={index}
center={{
latitude: point.lat,
longitude: point.lon,
}}
zIndex={50}
radius={20} // Tăng từ 50 → 1000m
fillColor="rgba(241, 12, 65, 0.8)" // Tăng opacity từ 0.06 → 0.8
strokeColor="rgba(221, 240, 15, 0.8)"
strokeWidth={2}
/>
);
})}
{gpsData && ( {gpsData && (
<Marker <Marker
coordinate={{ coordinate={{
latitude: gpsData.lat, latitude: gpsData.lat,
longitude: gpsData.lon, longitude: gpsData.lon,
}} }}
title="Device Location" title="Tàu của mình"
description={`Lat: ${gpsData.lat}, Lon: ${gpsData.lon}`} zIndex={100}
>
<View
style={{
transform: [
{
rotate: `${
typeof gpsData.h === "number" && !isNaN(gpsData.h)
? gpsData.h
: 0
}deg`,
},
],
alignItems: "center",
justifyContent: "center",
}}
>
<ExpoImage
source={getShipIcon(alarmData?.level || 0, gpsData.fishing)}
style={{ width: 32, height: 32 }}
/> />
</View>
</Marker>
)} )}
</MapView> </MapView>
<TouchableOpacity style={styles.button} onPress={getGpsData}> <TouchableOpacity style={styles.button} onPress={handleMapReady}>
<Text style={styles.buttonText}>Get GPS Data</Text> <Text style={styles.buttonText}>Get GPS Data</Text>
</TouchableOpacity> </TouchableOpacity>
</SafeAreaProvider> </SafeAreaProvider>

View File

@@ -11,7 +11,7 @@ import Toast from "react-native-toast-message";
import { setRouterInstance } from "@/config/auth"; import { setRouterInstance } from "@/config/auth";
import { useColorScheme } from "@/hooks/use-color-scheme"; import { useColorScheme } from "@/hooks/use-color-scheme";
import "../global.css";
export default function RootLayout() { export default function RootLayout() {
const colorScheme = useColorScheme(); const colorScheme = useColorScheme();
const router = useRouter(); const router = useRouter();

View File

@@ -73,7 +73,7 @@ export default function LoginScreen() {
console.log("Login thành công với data:", response.data); console.log("Login thành công với data:", response.data);
if (response?.data.token) { if (response?.data.token) {
// Lưu token vào storage nếu cần (thêm logic này sau) // Lưu token vào storage nếu cần (thêm logic này sau)
console.log("Login Token:", response.data.token); console.log("Login Token ");
await setStorageItem(TOKEN, response.data.token); await setStorageItem(TOKEN, response.data.token);
console.log("Token:", response.data.token); console.log("Token:", response.data.token);

BIN
assets/icons/alarm_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
assets/icons/marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/icons/ship_alarm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
assets/icons/sos_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

9
babel.config.js Normal file
View File

@@ -0,0 +1,9 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
};
};

View File

@@ -1,31 +1,34 @@
import { StyleSheet, Text, type TextProps } from 'react-native'; import { StyleSheet, Text, type TextProps } from "react-native";
import { useThemeColor } from '@/hooks/use-theme-color'; import { useThemeColor } from "@/hooks/use-theme-color";
export type ThemedTextProps = TextProps & { export type ThemedTextProps = TextProps & {
lightColor?: string; lightColor?: string;
darkColor?: string; darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link'; type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link";
className?: string;
}; };
export function ThemedText({ export function ThemedText({
style, style,
className = "",
lightColor, lightColor,
darkColor, darkColor,
type = 'default', type = "default",
...rest ...rest
}: ThemedTextProps) { }: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text'); const color = useThemeColor({ light: lightColor, dark: darkColor }, "text");
return ( return (
<Text <Text
className={className}
style={[ style={[
{ color }, { color },
type === 'default' ? styles.default : undefined, type === "default" ? styles.default : undefined,
type === 'title' ? styles.title : undefined, type === "title" ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined, type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined, type === "subtitle" ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined, type === "link" ? styles.link : undefined,
style, style,
]} ]}
{...rest} {...rest}
@@ -41,20 +44,20 @@ const styles = StyleSheet.create({
defaultSemiBold: { defaultSemiBold: {
fontSize: 16, fontSize: 16,
lineHeight: 24, lineHeight: 24,
fontWeight: '600', fontWeight: "600",
}, },
title: { title: {
fontSize: 32, fontSize: 32,
fontWeight: 'bold', fontWeight: "bold",
lineHeight: 32, lineHeight: 32,
}, },
subtitle: { subtitle: {
fontSize: 20, fontSize: 20,
fontWeight: 'bold', fontWeight: "bold",
}, },
link: { link: {
lineHeight: 30, lineHeight: 30,
fontSize: 16, fontSize: 16,
color: '#0a7ea4', color: "#0a7ea4",
}, },
}); });

View File

@@ -1,14 +1,30 @@
import { View, type ViewProps } from 'react-native'; import { View, type ViewProps } from "react-native";
import { useThemeColor } from '@/hooks/use-theme-color'; import { useThemeColor } from "@/hooks/use-theme-color";
export type ThemedViewProps = ViewProps & { export type ThemedViewProps = ViewProps & {
lightColor?: string; lightColor?: string;
darkColor?: string; darkColor?: string;
className?: string;
}; };
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { export function ThemedView({
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); style,
className = "",
lightColor,
darkColor,
...otherProps
}: ThemedViewProps) {
const backgroundColor = useThemeColor(
{ light: lightColor, dark: darkColor },
"background"
);
return <View style={[{ backgroundColor }, style]} {...otherProps} />; return (
<View
className={className}
style={[{ backgroundColor }, style]}
{...otherProps}
/>
);
} }

View File

@@ -30,13 +30,13 @@ const api: AxiosInstance = axios.create({
}, },
}); });
console.log("🚀 Axios baseURL:", api.defaults.baseURL);
// Interceptor cho request (thêm token nếu cần) // Interceptor cho request (thêm token nếu cần)
api.interceptors.request.use( api.interceptors.request.use(
async (config) => { async (config) => {
// Thêm auth token nếu có // Thêm auth token nếu có
const token = await getStorageItem(TOKEN); const token = await getStorageItem(TOKEN);
console.log("Request Token: ", token);
if (token) { if (token) {
config.headers.Authorization = `${token}`; config.headers.Authorization = `${token}`;
} }
@@ -53,7 +53,14 @@ api.interceptors.response.use(
return response; return response;
}, },
(error) => { (error) => {
// Xử lý lỗi chung, ví dụ: redirect login nếu 401
if (!error.response) {
const networkErrorMsg =
error.message || "Network error - please check connection";
showToastError("Lỗi kết nối", networkErrorMsg);
return Promise.reject(error);
}
const { status, statusText, data } = error.response; const { status, statusText, data } = error.response;
// Ưu tiên: codeMessage → backend message → statusText // Ưu tiên: codeMessage → backend message → statusText
@@ -62,8 +69,10 @@ api.interceptors.response.use(
data?.message || data?.message ||
statusText || statusText ||
"Unknown error"; "Unknown error";
showToastError(errMsg);
if (error.response?.status === 401) { showToastError(`Lỗi ${status}`, errMsg);
if (status === 401) {
// handle401(); // handle401();
} }
return Promise.reject(error); return Promise.reject(error);

View File

@@ -1,6 +1,18 @@
import { api } from "@/config"; import { api } from "@/config";
import { API_GET_GPS } from "@/constants"; import {
API_GET_ALARMS,
API_GET_GPS,
API_PATH_SHIP_TRACK_POINTS,
} from "@/constants";
export async function fetchGpsData() { export async function queryGpsData() {
return api.get<Model.GPSResonse>(API_GET_GPS); return api.get<Model.GPSResonse>(API_GET_GPS);
} }
export async function queryAlarm() {
return api.get<Model.AlarmResponse>(API_GET_ALARMS);
}
export async function queryTrackPoints() {
return api.get<Model.ShipTrackPoint[]>(API_PATH_SHIP_TRACK_POINTS);
}

View File

@@ -14,4 +14,23 @@ declare namespace Model {
h: number; h: number;
fishing: boolean; fishing: boolean;
} }
interface Alarm {
name: string;
t: number; // timestamp (epoch seconds)
level: number;
id: string;
}
interface AlarmResponse {
alarms: Alarm[];
level: number;
}
interface ShipTrackPoint {
time: number;
lon: number;
lat: number;
s: number;
h: number;
}
} }

3
global.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

6
metro.config.js Normal file
View File

@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: "./global.css" });

1
nativewind-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="nativewind/types" />

772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,13 +29,14 @@
"expo-symbols": "~1.0.7", "expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8", "expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"nativewind": "^4.2.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-maps": "^1.20.1", "react-native-maps": "^1.20.1",
"react-native-reanimated": "~4.1.1", "react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-toast-message": "^2.3.3", "react-native-toast-message": "^2.3.3",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
@@ -45,6 +46,8 @@
"@types/react": "~19.1.0", "@types/react": "~19.1.0",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.2" "typescript": "~5.9.2"
}, },
"private": true "private": true

30
services/map_service.ts Normal file
View File

@@ -0,0 +1,30 @@
import shipAlarmIcon from "../assets/icons/ship_alarm.png";
import shipAlarmFishingIcon from "../assets/icons/ship_alarm_fishing.png";
import shipOnlineIcon from "../assets/icons/ship_online.png";
import shipOnlineFishingIcon from "../assets/icons/ship_online_fishing.png";
import shipUndefineIcon from "../assets/icons/ship_undefine.png";
import shipWarningIcon from "../assets/icons/ship_warning.png";
import shipWarningFishingIcon from "../assets/icons/ship_warning_fishing.png";
import shipSosIcon from "../assets/icons/sos_icon.png";
export const getShipIcon = (type: number, isFishing: boolean) => {
console.log("type, isFishing", type, isFishing);
if (type === 1 && !isFishing) {
return shipWarningIcon;
} else if (type === 2 && !isFishing) {
return shipAlarmIcon;
} else if (type === 0 && !isFishing) {
return shipOnlineIcon;
} else if (type === 1 && isFishing) {
return shipWarningFishingIcon;
} else if (type === 2 && isFishing) {
return shipAlarmFishingIcon;
} else if (type === 0 && isFishing) {
return shipOnlineFishingIcon;
} else if (type === 3) {
return shipSosIcon;
} else {
return shipUndefineIcon;
}
};

13
tailwind.config.js Normal file
View File

@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,jsx,ts,tsx}",
"./components/**/*.{js,jsx,ts,tsx}",
"./screens/**/*.{js,jsx,ts,tsx}",
],
presets: [require("nativewind/preset")],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -2,6 +2,7 @@
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"jsx": "react-jsx",
"paths": { "paths": {
"@/*": [ "@/*": [
"./*" "./*"
@@ -12,6 +13,7 @@
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".expo/types/**/*.ts", ".expo/types/**/*.ts",
"expo-env.d.ts" "expo-env.d.ts",
"nativewind-env.d.ts"
] ]
} }