thêm giao diện cảnh báo

This commit is contained in:
Tran Anh Tuan
2025-12-09 11:37:19 +07:00
parent 7c3497d159
commit df4318fed4
14 changed files with 2546 additions and 1205 deletions

View File

@@ -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ả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",
},
});