315 lines
8.6 KiB
TypeScript
315 lines
8.6 KiB
TypeScript
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 { 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";
|
|
|
|
const PAGE_SIZE = 2;
|
|
|
|
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));
|
|
|
|
const { colors } = useThemeContext();
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
getAlarmsData(0, false);
|
|
}, []);
|
|
|
|
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]);
|
|
|
|
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);
|
|
|
|
const resp = await queryAlarms({
|
|
...usedParams,
|
|
offset: nextOffset,
|
|
});
|
|
const slice = resp.data?.alarms ?? [];
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const handleAlarmReload = useCallback((onReload: boolean) => {
|
|
if (onReload) {
|
|
getAlarmsData(0, false, undefined);
|
|
}
|
|
}, []);
|
|
|
|
const renderAlarmCard = useCallback(
|
|
({ item }: { item: Model.Alarm }) => (
|
|
<AlarmCard alarm={item} onReload={handleAlarmReload} />
|
|
),
|
|
[handleAlarmReload]
|
|
);
|
|
|
|
const keyExtractor = useCallback(
|
|
(item: Model.Alarm, index: number) =>
|
|
`${`${item.id} + ${item.time} + ${item.level} + ${index}` || index}`,
|
|
[]
|
|
);
|
|
|
|
const handleLoadMore = useCallback(() => {
|
|
if (isLoadingMore || !hasMore) return;
|
|
const nextOffset = offset + PAGE_SIZE;
|
|
getAlarmsData(nextOffset, true);
|
|
}, [isLoadingMore, hasMore, offset]);
|
|
|
|
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 style={styles.headerLeft}>
|
|
<ThemedText style={styles.titleText}>Cảnh báo</ThemedText>
|
|
</View>
|
|
<View style={styles.badgeContainer}>
|
|
<TouchableOpacity onPress={toggleSearchForm}>
|
|
<Ionicons
|
|
size={20}
|
|
name="filter-outline"
|
|
color={hasFilters ? colors.primary : colors.text}
|
|
/>
|
|
</TouchableOpacity>
|
|
</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 */}
|
|
{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: {
|
|
flex: 1,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
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: 24,
|
|
fontWeight: "700",
|
|
},
|
|
badgeContainer: {
|
|
// backgroundColor: "#dc2626",
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
borderRadius: 16,
|
|
},
|
|
badgeText: {
|
|
color: "#fff",
|
|
fontSize: 14,
|
|
fontWeight: "600",
|
|
},
|
|
listContent: {
|
|
paddingHorizontal: 16,
|
|
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",
|
|
},
|
|
});
|