thêm giao diện cảnh báo
This commit is contained in:
1494
ReactQuery_Axios.md
Normal file
1494
ReactQuery_Axios.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,302 +1,246 @@
|
||||
import { AlarmCard } from "@/components/alarm/AlarmCard";
|
||||
import AlarmSearchForm from "@/components/alarm/AlarmSearchForm";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { queryAlarms } from "@/controller/AlarmController";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { FlatList, StyleSheet, TouchableOpacity, View } from "react-native";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
FlatList,
|
||||
LayoutAnimation,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { AlarmData } from ".";
|
||||
|
||||
// ============ Types ============
|
||||
type AlarmType = "approaching" | "entered" | "fishing";
|
||||
const PAGE_SIZE = 2;
|
||||
|
||||
interface AlarmCardProps {
|
||||
alarm: AlarmData;
|
||||
onPress?: () => void;
|
||||
}
|
||||
const WarningScreen = () => {
|
||||
const [defaultAlarmParams, setDefaultAlarmParams] =
|
||||
useState<Model.AlarmPayload>({
|
||||
offset: 0,
|
||||
limit: PAGE_SIZE,
|
||||
order: "time",
|
||||
dir: "desc",
|
||||
});
|
||||
const [alarms, setAlarms] = useState<Model.Alarm[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [isShowSearchForm, setIsShowSearchForm] = useState(false);
|
||||
const [formOpacity] = useState(new Animated.Value(0));
|
||||
|
||||
// ============ Config ============
|
||||
const ALARM_CONFIG: Record<
|
||||
AlarmType,
|
||||
{
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
label: string;
|
||||
bgColor: string;
|
||||
borderColor: string;
|
||||
iconBgColor: string;
|
||||
iconColor: string;
|
||||
labelColor: string;
|
||||
}
|
||||
> = {
|
||||
entered: {
|
||||
icon: "warning",
|
||||
label: "Xâm nhập",
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconBgColor: "bg-red-100",
|
||||
iconColor: "#DC2626",
|
||||
labelColor: "text-red-600",
|
||||
},
|
||||
approaching: {
|
||||
icon: "alert-circle",
|
||||
label: "Tiếp cận",
|
||||
bgColor: "bg-amber-50",
|
||||
borderColor: "border-amber-200",
|
||||
iconBgColor: "bg-amber-100",
|
||||
iconColor: "#D97706",
|
||||
labelColor: "text-amber-600",
|
||||
},
|
||||
fishing: {
|
||||
icon: "fish",
|
||||
label: "Đánh bắt",
|
||||
bgColor: "bg-orange-50",
|
||||
borderColor: "border-orange-200",
|
||||
iconBgColor: "bg-orange-100",
|
||||
iconColor: "#EA580C",
|
||||
labelColor: "text-orange-600",
|
||||
},
|
||||
};
|
||||
const { colors } = useThemeContext();
|
||||
|
||||
// ============ Helper Functions ============
|
||||
const formatTimestamp = (timestamp?: number): string => {
|
||||
if (!timestamp) return "N/A";
|
||||
return dayjs.unix(timestamp).format("DD/MM/YYYY HH:mm:ss");
|
||||
};
|
||||
const hasFilters = useMemo(() => {
|
||||
return Boolean(
|
||||
(defaultAlarmParams as any)?.name ||
|
||||
((defaultAlarmParams as any)?.level !== undefined &&
|
||||
(defaultAlarmParams as any).level !== 0) ||
|
||||
(defaultAlarmParams as any)?.confirmed !== undefined
|
||||
);
|
||||
}, [defaultAlarmParams]);
|
||||
|
||||
// ============ AlarmCard Component ============
|
||||
const AlarmCard = React.memo(({ alarm, onPress }: AlarmCardProps) => {
|
||||
const config = ALARM_CONFIG[alarm.type];
|
||||
useEffect(() => {
|
||||
getAlarmsData(0, false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-2xl p-4 ${config.bgColor} ${config.borderColor} border shadow-sm`}
|
||||
>
|
||||
<View className="flex-row items-start gap-3">
|
||||
{/* Icon Container */}
|
||||
<View
|
||||
className={`w-12 h-12 rounded-xl items-center justify-center ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||
</View>
|
||||
useEffect(() => {
|
||||
if (isShowSearchForm) {
|
||||
// Reset opacity to 0, then animate to 1
|
||||
formOpacity.setValue(0);
|
||||
Animated.timing(formOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} else {
|
||||
formOpacity.setValue(0);
|
||||
}
|
||||
}, [isShowSearchForm, formOpacity]);
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1">
|
||||
{/* Header: Ship name + Badge */}
|
||||
<View className="flex-row items-center justify-between mb-1">
|
||||
<ThemedText className="text-base font-bold text-gray-800 flex-1 mr-2">
|
||||
{alarm.ship_name || alarm.thing_id}
|
||||
</ThemedText>
|
||||
<View className={`px-2 py-1 rounded-full ${config.iconBgColor}`}>
|
||||
<ThemedText
|
||||
className={`text-xs font-semibold ${config.labelColor}`}
|
||||
>
|
||||
{config.label}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
const getAlarmsData = async (
|
||||
nextOffset = 0,
|
||||
append = false,
|
||||
paramsOverride?: Model.AlarmPayload
|
||||
) => {
|
||||
try {
|
||||
if (append) setIsLoadingMore(true);
|
||||
else setLoading(true);
|
||||
// console.log("Call alarm with offset: ", nextOffset);
|
||||
const usedParams = paramsOverride ?? defaultAlarmParams;
|
||||
// console.log("params: ", usedParams);
|
||||
|
||||
{/* Zone Info */}
|
||||
<ThemedText className="text-sm text-gray-600 mb-2" numberOfLines={2}>
|
||||
{alarm.zone.message || alarm.zone.zone_name}
|
||||
</ThemedText>
|
||||
const resp = await queryAlarms({
|
||||
...usedParams,
|
||||
offset: nextOffset,
|
||||
});
|
||||
const slice = resp.data?.alarms ?? [];
|
||||
|
||||
{/* Footer: Zone ID + Time */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center gap-1">
|
||||
<Ionicons name="time-outline" size={20} color="#6B7280" />
|
||||
<ThemedText className="text-xs text-gray-500">
|
||||
{formatTimestamp(alarm.zone.gps_time)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
setAlarms((prev) => (append ? [...prev, ...slice] : slice));
|
||||
setOffset(nextOffset);
|
||||
setHasMore(nextOffset + PAGE_SIZE < resp.data?.total!);
|
||||
} catch (error) {
|
||||
console.error("Cannot get Alarm Data: ", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
AlarmCard.displayName = "AlarmCard";
|
||||
|
||||
// ============ Main Component ============
|
||||
interface WarningScreenProps {
|
||||
alarms?: AlarmData[];
|
||||
}
|
||||
|
||||
export default function WarningScreen({ alarms = [] }: WarningScreenProps) {
|
||||
// Mock data for demo - replace with actual props
|
||||
const sampleAlarms: AlarmData[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
thing_id: "SHIP-001",
|
||||
ship_name: "Ocean Star",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực cấm A1",
|
||||
zone_id: "A1",
|
||||
message: "Tàu đã đi vào vùng cấm A1",
|
||||
alarm_type: 1,
|
||||
lat: 10.12345,
|
||||
lon: 106.12345,
|
||||
s: 12,
|
||||
h: 180,
|
||||
fishing: false,
|
||||
gps_time: 1733389200,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-002",
|
||||
ship_name: "Blue Whale",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng cảnh báo B3",
|
||||
zone_id: "B3",
|
||||
message: "Tàu đang tiếp cận khu vực cấm B3",
|
||||
alarm_type: 2,
|
||||
lat: 9.87654,
|
||||
lon: 105.87654,
|
||||
gps_time: 1733389260,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-003",
|
||||
ship_name: "Sea Dragon",
|
||||
type: "fishing",
|
||||
zone: {
|
||||
zone_type: 3,
|
||||
zone_name: "Vùng cấm đánh bắt C2",
|
||||
zone_id: "C2",
|
||||
message: "Phát hiện hành vi đánh bắt trong vùng cấm C2",
|
||||
alarm_type: 3,
|
||||
lat: 11.11223,
|
||||
lon: 107.44556,
|
||||
fishing: true,
|
||||
gps_time: 1733389320,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-004",
|
||||
ship_name: "Red Coral",
|
||||
type: "entered",
|
||||
zone: {
|
||||
zone_type: 1,
|
||||
zone_name: "Khu vực A2",
|
||||
zone_id: "A2",
|
||||
message: "Tàu đã đi sâu vào khu vực A2",
|
||||
alarm_type: 1,
|
||||
gps_time: 1733389380,
|
||||
},
|
||||
},
|
||||
{
|
||||
thing_id: "SHIP-005",
|
||||
ship_name: "Silver Wind",
|
||||
type: "approaching",
|
||||
zone: {
|
||||
zone_type: 2,
|
||||
zone_name: "Vùng B1",
|
||||
zone_id: "B1",
|
||||
message: "Tàu đang tiến gần vào vùng B1",
|
||||
alarm_type: 2,
|
||||
gps_time: 1733389440,
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const displayAlarms = alarms.length > 0 ? alarms : sampleAlarms;
|
||||
|
||||
const handleAlarmPress = useCallback((alarm: AlarmData) => {
|
||||
console.log("Alarm pressed:", alarm);
|
||||
// TODO: Navigate to alarm detail or show modal
|
||||
const handleAlarmReload = useCallback((onReload: boolean) => {
|
||||
if (onReload) {
|
||||
getAlarmsData(0, false, undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderAlarmCard = useCallback(
|
||||
({ item }: { item: AlarmData }) => (
|
||||
<AlarmCard alarm={item} onPress={() => handleAlarmPress(item)} />
|
||||
({ item }: { item: Model.Alarm }) => (
|
||||
<AlarmCard alarm={item} onReload={handleAlarmReload} />
|
||||
),
|
||||
[handleAlarmPress]
|
||||
[handleAlarmReload]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: AlarmData, index: number) => `${item.thing_id}-${index}`,
|
||||
(item: Model.Alarm, index: number) =>
|
||||
`${`${item.id} + ${item.time} + ${item.level} + ${index}` || index}`,
|
||||
[]
|
||||
);
|
||||
|
||||
const ItemSeparator = useCallback(() => <View className="h-3" />, []);
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (isLoadingMore || !hasMore) return;
|
||||
const nextOffset = offset + PAGE_SIZE;
|
||||
getAlarmsData(nextOffset, true);
|
||||
}, [isLoadingMore, hasMore, offset]);
|
||||
|
||||
// Count alarms by type
|
||||
const alarmCounts = useMemo(() => {
|
||||
return displayAlarms.reduce((acc, alarm) => {
|
||||
acc[alarm.type] = (acc[alarm.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<AlarmType, number>);
|
||||
}, [displayAlarms]);
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
getAlarmsData(0, false, undefined);
|
||||
}, []);
|
||||
|
||||
const onSearch = useCallback(
|
||||
(values: { name?: string; level?: number; confirmed?: boolean }) => {
|
||||
const mapped = {
|
||||
offset: 0,
|
||||
limit: defaultAlarmParams.limit,
|
||||
order: defaultAlarmParams.order,
|
||||
dir: defaultAlarmParams.dir,
|
||||
...(values.name && { name: values.name }),
|
||||
...(values.level && values.level !== 0 && { level: values.level }),
|
||||
...(values.confirmed !== undefined && { confirmed: values.confirmed }),
|
||||
};
|
||||
|
||||
setDefaultAlarmParams(mapped);
|
||||
// Call getAlarmsData with the mapped params directly so the
|
||||
// request uses the updated params immediately (setState is async)
|
||||
getAlarmsData(0, false, mapped);
|
||||
toggleSearchForm();
|
||||
},
|
||||
[defaultAlarmParams]
|
||||
);
|
||||
|
||||
const toggleSearchForm = useCallback(() => {
|
||||
if (Platform.OS === "ios") {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
}
|
||||
|
||||
if (isShowSearchForm) {
|
||||
// Hide form
|
||||
Animated.timing(formOpacity, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setIsShowSearchForm(false);
|
||||
});
|
||||
} else {
|
||||
// Show form
|
||||
setIsShowSearchForm(true);
|
||||
Animated.timing(formOpacity, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [isShowSearchForm, formOpacity]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={["top"]}>
|
||||
<ThemedView style={styles.content}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="w-10 h-10 rounded-xl bg-red-500 items-center justify-center">
|
||||
<Ionicons name="warning" size={22} color="#fff" />
|
||||
</View>
|
||||
<View style={styles.headerLeft}>
|
||||
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
||||
</View>
|
||||
<View className="bg-red-500 px-3 py-1 rounded-full">
|
||||
<ThemedText className="text-white text-sm font-semibold">
|
||||
{displayAlarms.length}
|
||||
</ThemedText>
|
||||
<View style={styles.badgeContainer}>
|
||||
<TouchableOpacity onPress={toggleSearchForm}>
|
||||
<Ionicons
|
||||
size={20}
|
||||
name="filter-outline"
|
||||
color={hasFilters ? colors.primary : colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<View className="flex-row px-4 pb-3 gap-2">
|
||||
{(["entered", "approaching", "fishing"] as AlarmType[]).map(
|
||||
(type) => {
|
||||
const config = ALARM_CONFIG[type];
|
||||
const count = alarmCounts[type] || 0;
|
||||
return (
|
||||
<View
|
||||
key={type}
|
||||
className={`flex-1 flex-row items-center justify-center gap-1 py-2 rounded-lg ${config.iconBgColor}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={14}
|
||||
color={config.iconColor}
|
||||
/>
|
||||
<ThemedText
|
||||
className={`text-xs font-medium ${config.labelColor}`}
|
||||
>
|
||||
{count}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
{/* Search Form */}
|
||||
{isShowSearchForm && (
|
||||
<Animated.View style={{ opacity: formOpacity, zIndex: 100 }}>
|
||||
<AlarmSearchForm
|
||||
initialValue={{
|
||||
name: defaultAlarmParams.name || "",
|
||||
level: defaultAlarmParams.level || 0,
|
||||
confirmed: defaultAlarmParams.confirmed,
|
||||
}}
|
||||
onSubmit={onSearch}
|
||||
onReset={toggleSearchForm}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Alarm List */}
|
||||
<FlatList
|
||||
data={displayAlarms}
|
||||
renderItem={renderAlarmCard}
|
||||
keyExtractor={keyExtractor}
|
||||
ItemSeparatorComponent={ItemSeparator}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
/>
|
||||
{alarms.length > 0 ? (
|
||||
<FlatList
|
||||
data={alarms}
|
||||
renderItem={renderAlarmCard}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
ListFooterComponent={
|
||||
isLoadingMore ? (
|
||||
<View style={styles.footer}>
|
||||
<ActivityIndicator size="small" color="#dc2626" />
|
||||
<ThemedText style={styles.footerText}>Đang tải...</ThemedText>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="shield-checkmark" size={48} color="#16a34a" />
|
||||
<ThemedText style={styles.emptyText}>
|
||||
Không có cảnh báo nào
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default WarningScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -311,13 +255,60 @@ const styles = StyleSheet.create({
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#e5e7eb",
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#dc2626",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 26,
|
||||
fontSize: 24,
|
||||
fontWeight: "700",
|
||||
},
|
||||
badgeContainer: {
|
||||
// backgroundColor: "#dc2626",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 16,
|
||||
},
|
||||
badgeText: {
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
footer: {
|
||||
paddingVertical: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 16,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||
|
||||
interface ButtonCancelTripProps {
|
||||
title?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const ButtonCancelTrip: React.FC<ButtonCancelTripProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const displayTitle = title || t("trip.buttonCancelTrip.title");
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.text}>{displayTitle}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: "#f45b57", // đỏ nhẹ giống ảnh
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 20,
|
||||
alignSelf: "flex-start",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 2, // cho Android
|
||||
},
|
||||
text: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonCancelTrip;
|
||||
@@ -1,213 +0,0 @@
|
||||
import { queryGpsData } from "@/controller/DeviceController";
|
||||
import {
|
||||
queryStartNewHaul,
|
||||
queryUpdateTripState,
|
||||
} from "@/controller/TripController";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import {
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showWarningToast,
|
||||
} from "@/services/toast_service";
|
||||
import { useTrip } from "@/state/use-trip";
|
||||
import { AntDesign } from "@expo/vector-icons";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, StyleSheet, View } from "react-native";
|
||||
import IconButton from "./IconButton";
|
||||
import CreateOrUpdateHaulModal from "./tripInfo/modal/CreateOrUpdateHaulModal";
|
||||
|
||||
interface StartButtonProps {
|
||||
gpsData?: Model.GPSResponse;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
interface a {
|
||||
fishingLogs?: Model.FishingLogInfo[] | null;
|
||||
onCallback?: (fishingLogs: Model.FishingLogInfo[]) => void;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
const ButtonCreateNewHaulOrTrip: React.FC<StartButtonProps> = ({
|
||||
gpsData,
|
||||
onPress,
|
||||
}) => {
|
||||
const [isStarted, setIsStarted] = useState(false);
|
||||
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
const { trip, getTrip } = useTrip();
|
||||
useEffect(() => {
|
||||
getTrip();
|
||||
}, []);
|
||||
|
||||
const checkHaulFinished = () => {
|
||||
return trip?.fishing_logs?.some((h) => h.status === 0);
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
if (isStarted) {
|
||||
Alert.alert(t("trip.endHaulTitle"), t("trip.endHaulConfirm"), [
|
||||
{
|
||||
text: t("trip.cancelButton"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("trip.endButton"),
|
||||
onPress: () => {
|
||||
setIsStarted(false);
|
||||
Alert.alert(t("trip.successTitle"), t("trip.endHaulSuccess"));
|
||||
},
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
Alert.alert(t("trip.startHaulTitle"), t("trip.startHaulConfirm"), [
|
||||
{
|
||||
text: t("trip.cancelButton"),
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: t("trip.startButton"),
|
||||
onPress: () => {
|
||||
setIsStarted(true);
|
||||
Alert.alert(t("trip.successTitle"), t("trip.startHaulSuccess"));
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (onPress) {
|
||||
onPress();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTrip = async (state: number, note?: string) => {
|
||||
if (trip?.trip_status !== 2) {
|
||||
showWarningToast(t("trip.alreadyStarted"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await queryUpdateTripState({
|
||||
status: state,
|
||||
note: note || "",
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
showSuccessToast(t("trip.startTripSuccess"));
|
||||
await getTrip();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error stating trip :", error);
|
||||
showErrorToast("");
|
||||
}
|
||||
};
|
||||
|
||||
const createNewHaul = async () => {
|
||||
if (trip?.fishing_logs?.some((f) => f.status === 0)) {
|
||||
showWarningToast(t("trip.finishCurrentHaul"));
|
||||
return;
|
||||
}
|
||||
if (!gpsData) {
|
||||
const response = await queryGpsData();
|
||||
gpsData = response.data;
|
||||
}
|
||||
try {
|
||||
const body: Model.NewFishingLogRequest = {
|
||||
trip_id: trip?.id || "",
|
||||
start_at: new Date(),
|
||||
start_lat: gpsData.lat,
|
||||
start_lon: gpsData.lon,
|
||||
weather_description: t("trip.weatherDescription"),
|
||||
};
|
||||
|
||||
const resp = await queryStartNewHaul(body);
|
||||
if (resp.status === 200) {
|
||||
showSuccessToast(t("trip.startHaulSuccess"));
|
||||
await getTrip();
|
||||
} else {
|
||||
showErrorToast(t("trip.createHaulFailed"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// showErrorToast(t("trip.createHaulFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
|
||||
if (trip?.trip_status === 4 || trip?.trip_status === 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{trip?.trip_status === 2 ? (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" />}
|
||||
type="primary"
|
||||
style={{ backgroundColor: "green", borderRadius: 10 }}
|
||||
onPress={async () => handleStartTrip(3)}
|
||||
>
|
||||
{t("trip.startTrip")}
|
||||
</IconButton>
|
||||
) : checkHaulFinished() ? (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" color={"white"} />}
|
||||
type="primary"
|
||||
style={{ borderRadius: 10 }}
|
||||
onPress={() => setIsFinishHaulModalOpen(true)}
|
||||
>
|
||||
{t("trip.endHaul")}
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<AntDesign name="plus" color={"white"} />}
|
||||
type="primary"
|
||||
style={{ borderRadius: 10 }}
|
||||
onPress={async () => {
|
||||
createNewHaul();
|
||||
}}
|
||||
>
|
||||
{t("trip.startHaul")}
|
||||
</IconButton>
|
||||
)}
|
||||
<CreateOrUpdateHaulModal
|
||||
fishingLog={trip?.fishing_logs?.find((f) => f.status === 0)!}
|
||||
fishingLogIndex={trip?.fishing_logs?.length!}
|
||||
isVisible={isFinishHaulModalOpen}
|
||||
onClose={function (): void {
|
||||
setIsFinishHaulModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: "#4ecdc4", // màu ngọc lam
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
alignSelf: "flex-start",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 3,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
elevation: 3, // hiệu ứng nổi trên Android
|
||||
},
|
||||
buttonActive: {
|
||||
backgroundColor: "#e74c3c", // màu đỏ khi đang hoạt động
|
||||
},
|
||||
content: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
icon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
text: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonCreateNewHaulOrTrip;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import React from "react";
|
||||
import { StyleSheet, Text, TouchableOpacity } from "react-native";
|
||||
|
||||
interface ButtonEndTripProps {
|
||||
title?: string;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const ButtonEndTrip: React.FC<ButtonEndTripProps> = ({ title, onPress }) => {
|
||||
const { t } = useI18n();
|
||||
const displayTitle = title || t("trip.buttonEndTrip.title");
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.button}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.text}>{displayTitle}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
backgroundColor: "#ed9434", // màu cam sáng
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 28,
|
||||
alignSelf: "flex-start",
|
||||
shadowColor: "#000",
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 2, // hiệu ứng nổi trên Android
|
||||
},
|
||||
text: {
|
||||
color: "#fff",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export default ButtonEndTrip;
|
||||
439
components/alarm/AlarmCard.tsx
Normal file
439
components/alarm/AlarmCard.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import {
|
||||
queryConfirmAlarm,
|
||||
queryrUnconfirmAlarm,
|
||||
} from "@/controller/AlarmController";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
|
||||
interface AlarmCardProps {
|
||||
alarm: Model.Alarm;
|
||||
onReload?: (onReload: boolean) => void;
|
||||
}
|
||||
|
||||
export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
|
||||
const { colors } = useThemeContext();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const canSubmit = useMemo(
|
||||
() => note.trim().length > 0 || alarm.confirmed,
|
||||
[note, alarm.confirmed]
|
||||
);
|
||||
|
||||
// Determine level and colors based on alarm level
|
||||
const getAlarmConfig = (level?: number) => {
|
||||
if (level === 3) {
|
||||
// Danger - Red
|
||||
return {
|
||||
level: 3,
|
||||
icon: "warning" as const,
|
||||
bgColor: "#fee2e2",
|
||||
borderColor: "#DC0E0E",
|
||||
iconColor: "#dc2626",
|
||||
statusBg: "#dcfce7",
|
||||
statusText: "#166534",
|
||||
};
|
||||
} else if (level === 2) {
|
||||
// Caution - Yellow/Orange
|
||||
return {
|
||||
level: 2,
|
||||
icon: "alert-circle" as const,
|
||||
bgColor: "#fef3c7",
|
||||
borderColor: "#FF6C0C",
|
||||
iconColor: "#d97706",
|
||||
statusBg: "#fef08a",
|
||||
statusText: "#713f12",
|
||||
};
|
||||
} else {
|
||||
// Info - Green
|
||||
return {
|
||||
level: 1,
|
||||
icon: "information-circle" as const,
|
||||
bgColor: "#fffefe",
|
||||
borderColor: "#FF937E",
|
||||
iconColor: "#FF937E",
|
||||
statusBg: "#dcfce7",
|
||||
statusText: "#166534",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getAlarmConfig(alarm.level);
|
||||
|
||||
const formatDate = (timestamp?: number) => {
|
||||
if (!timestamp) return "N/A";
|
||||
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||
};
|
||||
|
||||
const ensurePayload = () => {
|
||||
if (!alarm.id || !alarm.thing_id || !alarm.time) {
|
||||
Alert.alert("Thiếu dữ liệu", "Không đủ thông tin để xác nhận cảnh báo");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const submitConfirm = async (action: "confirm" | "unconfirm") => {
|
||||
if (!ensurePayload()) return;
|
||||
if (action === "confirm" && note.trim().length === 0) {
|
||||
Alert.alert("Thông báo", "Vui lòng nhập ghi chú để xác nhận");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (action === "confirm") {
|
||||
await queryConfirmAlarm({
|
||||
id: alarm.id!,
|
||||
thing_id: alarm.thing_id!,
|
||||
time: alarm.time!,
|
||||
description: note.trim(),
|
||||
});
|
||||
} else {
|
||||
await queryrUnconfirmAlarm({
|
||||
id: alarm.id!,
|
||||
thing_id: alarm.thing_id!,
|
||||
time: alarm.time!,
|
||||
});
|
||||
}
|
||||
onReload?.(true);
|
||||
} catch (error: any) {
|
||||
console.error("Cannot confirm/unconfirm alarm: ", error);
|
||||
const status = error?.response?.status ?? error?.status;
|
||||
// If server returns 404, ignore silently
|
||||
if (status !== 404) {
|
||||
Alert.alert("Lỗi", "Không thể xử lý yêu cầu. Vui lòng thử lại.");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
setShowModal(false);
|
||||
setNote("");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePress = (alarm: Model.Alarm) => {
|
||||
if (alarm.confirmed) {
|
||||
Alert.alert(
|
||||
"Thông báo",
|
||||
"Bạn có chắc muốn ngừng xác nhận cảnh báo này?",
|
||||
[
|
||||
{ text: "Hủy", style: "cancel" },
|
||||
{
|
||||
text: "Ngừng xác nhận",
|
||||
style: "destructive",
|
||||
onPress: () => submitConfirm("unconfirm"),
|
||||
},
|
||||
]
|
||||
);
|
||||
} else {
|
||||
setShowModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: config.bgColor,
|
||||
borderLeftColor: config.borderColor,
|
||||
borderLeftWidth: 5,
|
||||
boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.2)",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* Left Side - Icon and Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Icon */}
|
||||
<View
|
||||
style={[styles.iconContainer, { backgroundColor: config.bgColor }]}
|
||||
>
|
||||
<Ionicons name={config.icon} size={24} color={config.iconColor} />
|
||||
</View>
|
||||
|
||||
{/* Title and Info */}
|
||||
<View style={styles.textContainer}>
|
||||
{/* Name */}
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
style={[styles.title, { color: colors.text }]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{alarm.name || alarm.thing_name || "Unknown"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Location (thing_name) and Time */}
|
||||
<View style={styles.infoRow}>
|
||||
<View style={styles.infoItem}>
|
||||
<Text
|
||||
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||
>
|
||||
Trạm
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.infoValue, { color: colors.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{alarm.thing_name || "Unknown"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoItem}>
|
||||
<Text
|
||||
style={[styles.infoLabel, { color: colors.textSecondary }]}
|
||||
>
|
||||
Thời gian
|
||||
</Text>
|
||||
<Text
|
||||
style={[styles.infoValue, { color: colors.text }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{formatDate(alarm.time)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status Badge */}
|
||||
<TouchableOpacity
|
||||
style={styles.statusContainer}
|
||||
onPress={() => handlePress(alarm)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
{
|
||||
backgroundColor: alarm.confirmed ? "#8FD14F" : "#EEEEEE",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusText,
|
||||
{ color: alarm.confirmed ? "#166534" : "black" },
|
||||
]}
|
||||
>
|
||||
{alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{alarm.confirmed && (
|
||||
<View style={styles.rightIcon}>
|
||||
<Ionicons
|
||||
name="checkmark-done"
|
||||
size={20}
|
||||
color={alarm.confirmed ? "#78C841" : config.iconColor}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Modal
|
||||
visible={showModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowModal(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
{ backgroundColor: colors.background },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, { color: colors.text }]}>
|
||||
Nhập ghi chú xác nhận
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Nhập ghi chú"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
multiline
|
||||
value={note}
|
||||
onChangeText={setNote}
|
||||
editable={!submitting}
|
||||
/>
|
||||
<View style={styles.modalActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
onPress={() => {
|
||||
setShowModal(false);
|
||||
setNote("");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Text style={styles.cancelText}>Hủy</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.modalButton,
|
||||
styles.confirmButton,
|
||||
!canSubmit && styles.disabledButton,
|
||||
]}
|
||||
onPress={() => submitConfirm("confirm")}
|
||||
disabled={submitting || !canSubmit}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<Text style={styles.confirmText}>Xác nhận</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
// borderWidth: 1,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
// marginRight: 5,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
titleRow: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
code: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
marginBottom: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
gap: 16,
|
||||
},
|
||||
infoItem: {
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
statusContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
statusBadge: {
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 20,
|
||||
borderWidth: 0.2,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
},
|
||||
rightIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: 12,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
modalContent: {
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: "700",
|
||||
},
|
||||
input: {
|
||||
minHeight: 80,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e5e7eb",
|
||||
padding: 12,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
modalActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: 12,
|
||||
},
|
||||
modalButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: "#e5e7eb",
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: "#dc2626",
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
cancelText: {
|
||||
color: "#111827",
|
||||
fontWeight: "600",
|
||||
},
|
||||
confirmText: {
|
||||
color: "#fff",
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
|
||||
export default AlarmCard;
|
||||
305
components/alarm/AlarmSearchForm.tsx
Normal file
305
components/alarm/AlarmSearchForm.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import Select, { SelectOption } from "@/components/Select";
|
||||
import { ThemedText } from "@/components/themed-text";
|
||||
import { ThemedView } from "@/components/themed-view";
|
||||
import { useThemeContext } from "@/hooks/use-theme-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { StyleSheet, TextInput, TouchableOpacity, View } from "react-native";
|
||||
|
||||
interface AlarmSearchFormProps {
|
||||
initialValue?: {
|
||||
name?: string;
|
||||
level?: number;
|
||||
confirmed?: boolean;
|
||||
};
|
||||
onSubmit: (payload: {
|
||||
name?: string;
|
||||
level?: number;
|
||||
confirmed?: boolean;
|
||||
}) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
level: number;
|
||||
confirmed: string; // Using string for Select component compatibility
|
||||
}
|
||||
|
||||
const AlarmSearchForm: React.FC<AlarmSearchFormProps> = ({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
onReset,
|
||||
}) => {
|
||||
const { colors } = useThemeContext();
|
||||
|
||||
const levelOptions: SelectOption[] = [
|
||||
{ label: "Tất cả", value: 0 },
|
||||
{ label: "Cảnh báo", value: 1 },
|
||||
{ label: "Nguy hiểm", value: 2 },
|
||||
];
|
||||
|
||||
const confirmedOptions: SelectOption[] = [
|
||||
{ label: "Tất cả", value: "" },
|
||||
{ label: "Đã xác nhận", value: "true" },
|
||||
{ label: "Chưa xác nhận", value: "false" },
|
||||
];
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
name: initialValue?.name || "",
|
||||
level: initialValue?.level || 0,
|
||||
confirmed:
|
||||
initialValue?.confirmed !== undefined
|
||||
? initialValue.confirmed.toString()
|
||||
: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
reset({
|
||||
name: initialValue.name || "",
|
||||
level: initialValue.level || 0,
|
||||
confirmed:
|
||||
initialValue.confirmed !== undefined
|
||||
? initialValue.confirmed.toString()
|
||||
: "",
|
||||
});
|
||||
}
|
||||
}, [initialValue, reset]);
|
||||
|
||||
const onFormSubmit = (data: FormData) => {
|
||||
const payload: {
|
||||
name?: string;
|
||||
level?: number;
|
||||
confirmed?: boolean;
|
||||
} = {
|
||||
...(data.name && { name: data.name }),
|
||||
...(data.level !== 0 && { level: data.level }),
|
||||
...(data.confirmed !== "" && {
|
||||
confirmed: data.confirmed === "true",
|
||||
}),
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset({
|
||||
name: "",
|
||||
level: 0,
|
||||
confirmed: undefined,
|
||||
});
|
||||
|
||||
// Submit empty payload to reset filters
|
||||
onSubmit({});
|
||||
onReset?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: colors.background,
|
||||
borderBottomColor: colors.border,
|
||||
height: "auto",
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
{/* Search Input */}
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<View style={styles.inputContainer}>
|
||||
<ThemedText style={styles.label}>Tìm kiếm</ThemedText>
|
||||
<View
|
||||
style={[styles.inputWrapper, { borderColor: colors.border }]}
|
||||
>
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Tìm theo tên cảnh báo"
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{value ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange("")}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<Ionicons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.textSecondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Level and Confirmed Selects */}
|
||||
<View style={styles.row}>
|
||||
<View style={styles.halfWidth}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="level"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.selectContainer}>
|
||||
<ThemedText style={styles.label}>Mức độ</ThemedText>
|
||||
<Select
|
||||
placeholder="Chọn mức độ"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={levelOptions}
|
||||
size="middle"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.halfWidth}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmed"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<View style={styles.selectContainer}>
|
||||
<ThemedText style={styles.label}>Trạng thái</ThemedText>
|
||||
<Select
|
||||
placeholder="Chọn trạng thái"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={confirmedOptions}
|
||||
size="middle"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.secondaryButton,
|
||||
{
|
||||
backgroundColor: colors.backgroundSecondary,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={handleReset}
|
||||
>
|
||||
<ThemedText style={[styles.buttonText, { color: colors.text }]}>
|
||||
Đặt lại
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
styles.primaryButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
]}
|
||||
onPress={handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<ThemedText style={[styles.buttonText, { color: "#fff" }]}>
|
||||
Tìm kiếm
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderBottomWidth: 1,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
zIndex: 100,
|
||||
},
|
||||
content: {
|
||||
padding: 16,
|
||||
overflow: "visible",
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "500",
|
||||
marginBottom: 6,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
fontSize: 16,
|
||||
},
|
||||
clearButton: {
|
||||
marginLeft: 8,
|
||||
padding: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 16,
|
||||
zIndex: 10,
|
||||
},
|
||||
halfWidth: {
|
||||
width: "48%",
|
||||
zIndex: 5000,
|
||||
},
|
||||
selectContainer: {
|
||||
// flex: 1, // Remove this to prevent taking full width
|
||||
zIndex: 5000,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
secondaryButton: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
primaryButton: {
|
||||
// backgroundColor is set dynamically
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
},
|
||||
});
|
||||
|
||||
export default AlarmSearchForm;
|
||||
@@ -1,197 +0,0 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import dayjs from "dayjs";
|
||||
import { FlatList, Text, TouchableOpacity, View } from "react-native";
|
||||
|
||||
export type AlarmStatus = "confirmed" | "pending";
|
||||
|
||||
export interface AlarmListItem {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
station: string;
|
||||
timestamp: number;
|
||||
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
|
||||
status: AlarmStatus;
|
||||
}
|
||||
|
||||
type AlarmProp = {
|
||||
alarmsData: AlarmListItem[];
|
||||
onPress?: (alarm: AlarmListItem) => void;
|
||||
};
|
||||
|
||||
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
|
||||
return (
|
||||
<FlatList
|
||||
data={alarmsData}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
|
||||
ItemSeparatorComponent={() => <View className="h-3" />}
|
||||
renderItem={({ item }) => (
|
||||
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AlarmCardProps = {
|
||||
alarm: AlarmListItem;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
|
||||
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
|
||||
alarm.level
|
||||
);
|
||||
const statusConfig = getStatusConfig(alarm.status);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
|
||||
>
|
||||
<View className="flex-row justify-between items-start">
|
||||
{/* Left content */}
|
||||
<View className="flex-row flex-1">
|
||||
{/* Icon */}
|
||||
<View
|
||||
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
|
||||
>
|
||||
<Ionicons
|
||||
name={getIconByLevel(alarm.level)}
|
||||
size={20}
|
||||
color={iconColor}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View className="flex-1">
|
||||
{/* Code */}
|
||||
<Text
|
||||
className={`text-xs font-medium mb-1 ${getCodeTextColor(
|
||||
alarm.level
|
||||
)}`}
|
||||
>
|
||||
{alarm.code}
|
||||
</Text>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="text-base font-semibold text-gray-800 mb-2">
|
||||
{alarm.title}
|
||||
</Text>
|
||||
|
||||
{/* Station and Time */}
|
||||
<View className="flex-row">
|
||||
<View className="mr-6">
|
||||
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
|
||||
<Text className="text-sm text-gray-600">{alarm.station}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
|
||||
<Text className="text-sm text-gray-600">
|
||||
{formatTimestamp(alarm.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status Badge */}
|
||||
{/* <View className="mt-3">
|
||||
<View
|
||||
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-medium ${statusConfig.textColor}`}
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View> */}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Checkmark for confirmed */}
|
||||
{/* {alarm.status === "confirmed" && (
|
||||
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
|
||||
<Ionicons name="checkmark" size={16} color="white" />
|
||||
</View>
|
||||
)} */}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const getColorsByLevel = (level: number) => {
|
||||
switch (level) {
|
||||
case 3: // Danger - Red
|
||||
return {
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
iconColor: "#DC2626",
|
||||
iconBgColor: "bg-red-100",
|
||||
};
|
||||
case 2: // Caution - Yellow/Orange
|
||||
return {
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
iconColor: "#CA8A04",
|
||||
iconBgColor: "bg-yellow-100",
|
||||
};
|
||||
case 1: // Info - Green
|
||||
default:
|
||||
return {
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
iconColor: "#16A34A",
|
||||
iconBgColor: "bg-green-100",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
|
||||
switch (level) {
|
||||
case 3:
|
||||
return "warning";
|
||||
case 2:
|
||||
return "alert-circle";
|
||||
case 1:
|
||||
default:
|
||||
return "checkmark-circle";
|
||||
}
|
||||
};
|
||||
|
||||
const getCodeTextColor = (level: number) => {
|
||||
switch (level) {
|
||||
case 3:
|
||||
return "text-red-600";
|
||||
case 2:
|
||||
return "text-yellow-600";
|
||||
case 1:
|
||||
default:
|
||||
return "text-green-600";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: AlarmStatus) => {
|
||||
switch (status) {
|
||||
case "confirmed":
|
||||
return {
|
||||
label: "Đã xác nhận",
|
||||
bgColor: "bg-green-100",
|
||||
textColor: "text-green-700",
|
||||
};
|
||||
case "pending":
|
||||
default:
|
||||
return {
|
||||
label: "Chờ xác nhận",
|
||||
bgColor: "bg-yellow-100",
|
||||
textColor: "text-yellow-700",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
|
||||
};
|
||||
|
||||
export default AlarmList;
|
||||
@@ -1,249 +0,0 @@
|
||||
import {
|
||||
queryDeleteSos,
|
||||
queryGetSos,
|
||||
querySendSosMessage,
|
||||
} from "@/controller/DeviceController";
|
||||
import { useI18n } from "@/hooks/use-i18n";
|
||||
import { showErrorToast } from "@/services/toast_service";
|
||||
import { sosMessage } from "@/utils/sosUtils";
|
||||
import { MaterialIcons } from "@expo/vector-icons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { StyleSheet, Text, TextInput, View } from "react-native";
|
||||
import IconButton from "../IconButton";
|
||||
import Select from "../Select";
|
||||
import Modal from "../ui/modal";
|
||||
import { useThemeColor } from "@/hooks/use-theme-color";
|
||||
|
||||
const SosButton = () => {
|
||||
const [sosData, setSosData] = useState<Model.SosResponse | null>();
|
||||
const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
|
||||
const [selectedSosMessage, setSelectedSosMessage] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [customMessage, setCustomMessage] = useState("");
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
const { t } = useI18n();
|
||||
|
||||
// Theme colors
|
||||
const textColor = useThemeColor({}, 'text');
|
||||
const borderColor = useThemeColor({}, 'border');
|
||||
const errorColor = useThemeColor({}, 'error');
|
||||
const backgroundColor = useThemeColor({}, 'background');
|
||||
|
||||
// Dynamic styles
|
||||
const styles = SosButtonStyles(textColor, borderColor, errorColor, backgroundColor);
|
||||
|
||||
const sosOptions = [
|
||||
...sosMessage.map((msg) => ({
|
||||
ma: msg.ma,
|
||||
moTa: msg.moTa,
|
||||
label: msg.moTa,
|
||||
value: msg.ma,
|
||||
})),
|
||||
{ ma: 999, moTa: "Khác", label: "Khác", value: 999 },
|
||||
];
|
||||
|
||||
const getSosData = async () => {
|
||||
try {
|
||||
const response = await queryGetSos();
|
||||
// console.log("SoS ResponseL: ", response);
|
||||
|
||||
setSosData(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch SOS data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSosData();
|
||||
}, []);
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: { [key: string]: string } = {};
|
||||
|
||||
if (selectedSosMessage === 999 && customMessage.trim() === "") {
|
||||
newErrors.customMessage = t("home.sos.statusRequired");
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleConfirmSos = async () => {
|
||||
if (!validateForm()) {
|
||||
console.log("Form chưa validate");
|
||||
return; // Không đóng modal nếu validate fail
|
||||
}
|
||||
|
||||
let messageToSend = "";
|
||||
if (selectedSosMessage === 999) {
|
||||
messageToSend = customMessage.trim();
|
||||
} else {
|
||||
const selectedOption = sosOptions.find(
|
||||
(opt) => opt.ma === selectedSosMessage
|
||||
);
|
||||
messageToSend = selectedOption ? selectedOption.moTa : "";
|
||||
}
|
||||
|
||||
// Gửi dữ liệu đi
|
||||
await sendSosMessage(messageToSend);
|
||||
|
||||
// Đóng modal và reset form sau khi gửi thành công
|
||||
setShowConfirmSosDialog(false);
|
||||
setSelectedSosMessage(null);
|
||||
setCustomMessage("");
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
const handleClickButton = async (isActive: boolean) => {
|
||||
console.log("Is Active: ", isActive);
|
||||
|
||||
if (isActive) {
|
||||
const resp = await queryDeleteSos();
|
||||
if (resp.status === 200) {
|
||||
await getSosData();
|
||||
}
|
||||
} else {
|
||||
setSelectedSosMessage(11); // Mặc định chọn lý do ma: 11
|
||||
setShowConfirmSosDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const sendSosMessage = async (message: string) => {
|
||||
try {
|
||||
const resp = await querySendSosMessage(message);
|
||||
if (resp.status === 200) {
|
||||
await getSosData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error when send sos: ", error);
|
||||
showErrorToast(t("home.sos.sendError"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<MaterialIcons name="warning" size={20} color="white" />}
|
||||
type="danger"
|
||||
size="middle"
|
||||
onPress={() => handleClickButton(sosData?.active || false)}
|
||||
style={{ borderRadius: 20 }}
|
||||
>
|
||||
{sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
|
||||
</IconButton>
|
||||
<Modal
|
||||
open={showConfirmSosDialog}
|
||||
onCancel={() => {
|
||||
setShowConfirmSosDialog(false);
|
||||
setSelectedSosMessage(null);
|
||||
setCustomMessage("");
|
||||
setErrors({});
|
||||
}}
|
||||
okText={t("home.sos.confirm")}
|
||||
cancelText={t("home.sos.cancel")}
|
||||
title={t("home.sos.title")}
|
||||
centered
|
||||
onOk={handleConfirmSos}
|
||||
>
|
||||
{/* Select Nội dung SOS */}
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>{t("home.sos.content")}</Text>
|
||||
|
||||
<Select
|
||||
value={selectedSosMessage ?? undefined}
|
||||
options={sosOptions}
|
||||
placeholder={t("home.sos.selectReason")}
|
||||
onChange={(value) => {
|
||||
setSelectedSosMessage(value as number);
|
||||
// Clear custom message nếu chọn khác lý do
|
||||
if (value !== 999) {
|
||||
setCustomMessage("");
|
||||
}
|
||||
// Clear error if exists
|
||||
if (errors.sosMessage) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors.sosMessage;
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
showSearch={false}
|
||||
style={[errors.sosMessage ? styles.errorBorder : undefined]}
|
||||
/>
|
||||
{errors.sosMessage && (
|
||||
<Text style={styles.errorText}>{errors.sosMessage}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Input Custom Message nếu chọn "Khác" */}
|
||||
{selectedSosMessage === 999 && (
|
||||
<View style={styles.formGroup}>
|
||||
<Text style={styles.label}>{t("home.sos.statusInput")}</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
errors.customMessage ? styles.errorInput : {},
|
||||
]}
|
||||
placeholder={t("home.sos.enterStatus")}
|
||||
placeholderTextColor={textColor + '99'} // Add transparency
|
||||
value={customMessage}
|
||||
onChangeText={(text) => {
|
||||
setCustomMessage(text);
|
||||
if (text.trim() !== "") {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors.customMessage;
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
}}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
/>
|
||||
{errors.customMessage && (
|
||||
<Text style={styles.errorText}>{errors.customMessage}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SosButtonStyles = (textColor: string, borderColor: string, errorColor: string, backgroundColor: string) => StyleSheet.create({
|
||||
formGroup: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: "600",
|
||||
marginBottom: 8,
|
||||
color: textColor,
|
||||
},
|
||||
errorBorder: {
|
||||
borderColor: errorColor,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: borderColor,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
fontSize: 14,
|
||||
color: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
errorInput: {
|
||||
borderColor: errorColor,
|
||||
},
|
||||
errorText: {
|
||||
color: errorColor,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
export default SosButton;
|
||||
@@ -55,3 +55,5 @@ export const API_GET_ALL_BANZONES = "/api/sgw/banzones";
|
||||
export const API_GET_SHIP_TYPES = "/api/sgw/ships/types";
|
||||
export const API_GET_SHIP_GROUPS = "/api/sgw/shipsgroup";
|
||||
export const API_GET_LAST_TRIP = "/api/sgw/trips/last";
|
||||
export const API_GET_ALARM = "/api/alarms";
|
||||
export const API_MANAGER_ALARM = "/api/alarms/confirm";
|
||||
|
||||
15
controller/AlarmController.ts
Normal file
15
controller/AlarmController.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { api } from "@/config";
|
||||
import { API_GET_ALARM, API_MANAGER_ALARM } from "@/constants";
|
||||
|
||||
export async function queryAlarms(payload: Model.AlarmPayload) {
|
||||
return await api.get<Model.AlarmResponse>(API_GET_ALARM, {
|
||||
params: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function queryConfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||
return await api.post(API_MANAGER_ALARM, body);
|
||||
}
|
||||
export async function queryrUnconfirmAlarm(body: Model.AlarmConfirmRequest) {
|
||||
return await api.delete(API_MANAGER_ALARM, { data: body });
|
||||
}
|
||||
@@ -1,43 +1,9 @@
|
||||
import { api } from "@/config";
|
||||
import {
|
||||
API_GET_ALARMS,
|
||||
API_GET_GPS,
|
||||
API_GET_SHIP_GROUPS,
|
||||
API_GET_SHIP_TYPES,
|
||||
API_PATH_ENTITIES,
|
||||
API_PATH_SEARCH_THINGS,
|
||||
API_PATH_SHIP_TRACK_POINTS,
|
||||
API_SOS,
|
||||
} from "@/constants";
|
||||
import { transformEntityResponse } from "@/utils/tranform";
|
||||
|
||||
export async function queryGpsData() {
|
||||
return api.get<Model.GPSResponse>(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);
|
||||
}
|
||||
|
||||
export async function queryEntities(): Promise<Model.TransformedEntity[]> {
|
||||
const response = await api.get<Model.EntityResponse[]>(API_PATH_ENTITIES);
|
||||
return response.data.map(transformEntityResponse);
|
||||
}
|
||||
|
||||
export async function queryGetSos() {
|
||||
return await api.get<Model.SosResponse>(API_SOS);
|
||||
}
|
||||
export async function queryDeleteSos() {
|
||||
return await api.delete<Model.SosResponse>(API_SOS);
|
||||
}
|
||||
|
||||
export async function querySendSosMessage(message: string) {
|
||||
return await api.put<Model.SosRequest>(API_SOS, { message });
|
||||
}
|
||||
|
||||
export async function querySearchThings(body: Model.SearchThingBody) {
|
||||
return await api.post<Model.ThingsResponse>(API_PATH_SEARCH_THINGS, body);
|
||||
|
||||
74
controller/typings.d.ts
vendored
74
controller/typings.d.ts
vendored
@@ -17,41 +17,6 @@ declare namespace Model {
|
||||
fishing: boolean;
|
||||
t: number;
|
||||
}
|
||||
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;
|
||||
}
|
||||
interface EntityResponse {
|
||||
id: string;
|
||||
v: number;
|
||||
vs: string;
|
||||
t: number;
|
||||
type: string;
|
||||
}
|
||||
interface TransformedEntity {
|
||||
id: string;
|
||||
value: number;
|
||||
valueString: string;
|
||||
time: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Banzones
|
||||
// Banzone
|
||||
interface Zone {
|
||||
id?: string;
|
||||
@@ -332,4 +297,43 @@ declare namespace Model {
|
||||
owner_id?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface AlarmPayload {
|
||||
offset: number;
|
||||
limit: number;
|
||||
order?: string;
|
||||
dir?: "asc" | "desc";
|
||||
name?: string;
|
||||
level?: number;
|
||||
confirmed?: boolean;
|
||||
}
|
||||
|
||||
interface AlarmResponse {
|
||||
total?: number;
|
||||
limit?: number;
|
||||
order?: string;
|
||||
dir?: string;
|
||||
alarms?: Alarm[];
|
||||
}
|
||||
|
||||
interface Alarm {
|
||||
name?: string;
|
||||
time?: number;
|
||||
level?: number;
|
||||
id?: string;
|
||||
confirmed?: boolean;
|
||||
confirmed_email?: string;
|
||||
confirmed_time?: number;
|
||||
confirmed_desc?: string;
|
||||
thing_id?: string;
|
||||
thing_name?: string;
|
||||
thing_type?: ThingType;
|
||||
}
|
||||
|
||||
interface AlarmConfirmRequest {
|
||||
id: string;
|
||||
description?: string;
|
||||
thing_id: string;
|
||||
time: number;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,139 +1,20 @@
|
||||
import {
|
||||
AUTO_REFRESH_INTERVAL,
|
||||
EVENT_ALARM_DATA,
|
||||
EVENT_BANZONE_DATA,
|
||||
EVENT_ENTITY_DATA,
|
||||
EVENT_GPS_DATA,
|
||||
EVENT_SEARCH_THINGS,
|
||||
EVENT_TRACK_POINTS_DATA,
|
||||
} from "@/constants";
|
||||
import {
|
||||
queryAlarm,
|
||||
queryEntities,
|
||||
queryGpsData,
|
||||
querySearchThings,
|
||||
queryTrackPoints,
|
||||
} from "@/controller/DeviceController";
|
||||
import { querySearchThings } from "@/controller/DeviceController";
|
||||
import { queryBanzones } from "@/controller/MapController";
|
||||
import eventBus from "@/utils/eventBus";
|
||||
|
||||
const intervals: {
|
||||
gps: ReturnType<typeof setInterval> | null;
|
||||
alarm: ReturnType<typeof setInterval> | null;
|
||||
entities: ReturnType<typeof setInterval> | null;
|
||||
trackPoints: ReturnType<typeof setInterval> | null;
|
||||
banzones: ReturnType<typeof setInterval> | null;
|
||||
searchThings: ReturnType<typeof setInterval> | null;
|
||||
} = {
|
||||
gps: null,
|
||||
alarm: null,
|
||||
entities: null,
|
||||
trackPoints: null,
|
||||
banzones: null,
|
||||
searchThings: null,
|
||||
};
|
||||
|
||||
export function getGpsEventBus() {
|
||||
if (intervals.gps) return;
|
||||
// console.log("Starting GPS poller");
|
||||
|
||||
const getGpsData = async () => {
|
||||
try {
|
||||
// console.log("GPS: fetching data...");
|
||||
const resp = await queryGpsData();
|
||||
if (resp && resp.data) {
|
||||
// console.log("GPS: emitting data", resp.data);
|
||||
eventBus.emit(EVENT_GPS_DATA, resp.data);
|
||||
} else {
|
||||
console.log("GPS: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("GPS: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Run immediately once, then schedule
|
||||
getGpsData();
|
||||
intervals.gps = setInterval(() => {
|
||||
getGpsData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getAlarmEventBus() {
|
||||
if (intervals.alarm) return;
|
||||
// console.log("Goi ham get Alarm");
|
||||
const getAlarmData = async () => {
|
||||
try {
|
||||
// console.log("Alarm: fetching data...");
|
||||
const resp = await queryAlarm();
|
||||
if (resp && resp.data) {
|
||||
// console.log(
|
||||
// "Alarm: emitting data",
|
||||
// resp.data?.alarms?.length ?? resp.data
|
||||
// );
|
||||
eventBus.emit(EVENT_ALARM_DATA, resp.data);
|
||||
} else {
|
||||
console.log("Alarm: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Alarm: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
getAlarmData();
|
||||
intervals.alarm = setInterval(() => {
|
||||
getAlarmData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getEntitiesEventBus() {
|
||||
if (intervals.entities) return;
|
||||
// console.log("Goi ham get Entities");
|
||||
const getEntitiesData = async () => {
|
||||
try {
|
||||
// console.log("Entities: fetching data...");
|
||||
const resp = await queryEntities();
|
||||
if (resp && resp.length > 0) {
|
||||
// console.log("Entities: emitting", resp.length);
|
||||
eventBus.emit(EVENT_ENTITY_DATA, resp);
|
||||
} else {
|
||||
console.log("Entities: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Entities: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
getEntitiesData();
|
||||
intervals.entities = setInterval(() => {
|
||||
getEntitiesData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getTrackPointsEventBus() {
|
||||
if (intervals.trackPoints) return;
|
||||
// console.log("Goi ham get Track Points");
|
||||
const getTrackPointsData = async () => {
|
||||
try {
|
||||
// console.log("TrackPoints: fetching data...");
|
||||
const resp = await queryTrackPoints();
|
||||
if (resp && resp.data && resp.data.length > 0) {
|
||||
// console.log("TrackPoints: emitting", resp.data.length);
|
||||
eventBus.emit(EVENT_TRACK_POINTS_DATA, resp.data);
|
||||
} else {
|
||||
console.log("TrackPoints: no data returned");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("TrackPoints: fetch error", err);
|
||||
}
|
||||
};
|
||||
|
||||
getTrackPointsData();
|
||||
intervals.trackPoints = setInterval(() => {
|
||||
getTrackPointsData();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
export function getBanzonesEventBus() {
|
||||
if (intervals.banzones) return;
|
||||
const getBanzonesData = async () => {
|
||||
@@ -199,9 +80,5 @@ export function stopEvents() {
|
||||
}
|
||||
|
||||
export function startEvents() {
|
||||
getGpsEventBus();
|
||||
getAlarmEventBus();
|
||||
getEntitiesEventBus();
|
||||
getTrackPointsEventBus();
|
||||
getBanzonesEventBus();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user