thêm giao diện cảnh báo
This commit is contained in:
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user