diff --git a/ReactQuery_Axios.md b/ReactQuery_Axios.md
new file mode 100644
index 0000000..8d55b27
--- /dev/null
+++ b/ReactQuery_Axios.md
@@ -0,0 +1,1494 @@
+# React Query (TanStack Query) + Axios cho React Native với Expo
+
+## Mục Lục
+
+1. [React Query là gì?](#react-query-là-gì)
+2. [Tư duy Server-State vs Client-State](#tư-duy-server-state-vs-client-state)
+3. [Chiến thuật Caching trong React Query](#chiến-thuật-caching-trong-react-query)
+4. [Các Hooks quan trọng trong React Query](#các-hooks-quan-trọng-trong-react-query)
+5. [Kết hợp React Query + Axios](#kết-hợp-react-query--axios)
+6. [Ví dụ đầy đủ cho React Native + Expo](#ví dụ-đầy-đủ-cho-react-native--expo)
+7. [Tối ưu Performance cho React Native](#tối-optim-performance-cho-react-native)
+
+---
+
+## React Query là gì?
+
+React Query (nay là TanStack Query) là một library giúp quản lý **server-state** trong React applications. Nó giải quyết các vấn đề thường gặp khi làm việc với dữ liệu từ server:
+
+- **Fetching data**: Lấy dữ liệu từ API
+- **Caching**: Lưu trữ dữ liệu để tránh gọi API lại không cần thiết
+- **Synchronizing**: Đồng bộ dữ liệu khi có thay đổi từ server
+- **Updating**: Cập nhật dữ liệu sau khi mutation
+- **Managing state**: Quản lý các trạng thái (loading, error, success)
+
+### Tại sao nên dùng React Query?
+
+```javascript
+// ❌ Không dùng React Query
+function UserProfile() {
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ fetchUser()
+ .then(setUser)
+ .catch(setError)
+ .finally(() => setLoading(false));
+ }, []);
+
+ if (loading) return ;
+ if (error) return Error: {error.message};
+ return {user.name};
+}
+
+// ✅ Dùng React Query
+function UserProfile() {
+ const { data: user, isLoading, error } = useQuery({
+ queryKey: ['user'],
+ queryFn: fetchUser
+ });
+
+ if (isLoading) return ;
+ if (error) return Error: {error.message};
+ return {user.name};
+}
+```
+
+---
+
+## Tư duy Server-State vs Client-State
+
+### Server-State
+- **Nguồn gốc**: Đến từ server (API, database)
+- **Đặc điểm**: Có thể bị thay đổi bởi user khác, cần đồng bộ
+- **Ví dụ**: Danh sách sản phẩm, thông tin user, dữ liệu real-time
+
+### Client-State
+- **Nguồn gốc**: Tạo ra trong ứng dụng
+- **Đặc điểm**: Chỉ tồn tại trong client, không cần đồng bộ
+- **Ví dụ**: Theme dark/light, form input state, UI state
+
+```javascript
+function App() {
+ // ❌ Sai: Đẩy server-state vào client-state
+ const [products, setProducts] = useState([]);
+
+ // ✅ Đúng: Client-state cho UI, server-state cho data
+ const [isModalOpen, setIsModalOpen] = useState(false); // Client-state
+ const { data: products } = useQuery({ queryKey: ['products'], queryFn: fetchProducts }); // Server-state
+}
+```
+
+---
+
+## Chiến thuật Caching trong React Query
+
+### Stale-While-Revalidate (SWR)
+
+```javascript
+useQuery({
+ queryKey: ['posts'],
+ queryFn: fetchPosts,
+ staleTime: 1000 * 60, // 1 phút
+ cacheTime: 1000 * 60 * 5, // 5 phút
+});
+```
+
+**Luồng hoạt động:**
+
+1. **First request**: Gọi API → Lưu vào cache
+2. **Second request (trong 1 phút)**: Trả data từ cache (instant) ✨
+3. **After 1 minute**: Trả data từ cache + background refetch 🔄
+4. **After 5 minutes**: Xóa khỏi cache 🗑️
+
+### Các thời gian quan trọng
+
+```javascript
+{
+ staleTime: 0, // Luôn coi data là cũ (mỗi request đều gọi API)
+ staleTime: Infinity, // Luôn coi data là mới (không bao giờ refetch)
+
+ cacheTime: 1000 * 60 * 5, // Giữ cache 5 phút sau khi unused
+
+ refetchOnWindowFocus: true, // Refetch khi app focus (React Native)
+ refetchOnReconnect: true, // Refetch khi reconnect internet
+ refetchOnMount: false, // Không refetch khi component mount
+}
+```
+
+---
+
+## Các Hooks quan trọng trong React Query
+
+### 1. useQuery
+
+Dùng để fetch và cache data read-only.
+
+```javascript
+// Cú pháp cơ bản
+const {
+ data,
+ isLoading,
+ error,
+ refetch,
+ isSuccess,
+ isError
+} = useQuery({
+ queryKey: ['unique-key'],
+ queryFn: () => axios.get('/api/data'),
+ options: { /* options */ }
+});
+
+// Ví dụ thực tế
+function UserList() {
+ const {
+ data: users = [],
+ isLoading,
+ error
+ } = useQuery({
+ queryKey: ['users'],
+ queryFn: async () => {
+ const response = await axios.get('/api/users');
+ return response.data;
+ },
+ staleTime: 1000 * 60 * 5, // 5 phút
+ enabled: true, // Có enabled/disabled query
+ });
+
+ if (isLoading) return ;
+ if (error) return Lỗi: {error.message};
+
+ return (
+ {item.name}}
+ keyExtractor={item => item.id}
+ />
+ );
+}
+```
+
+**Options quan trọng:**
+
+```javascript
+{
+ // Dependencies - Query sẽ refetch khi dependencies thay đổi
+ queryKey: ['users', { page: 1, search: 'john' }],
+
+ // Conditional fetching
+ enabled: userId !== null,
+
+ // Retry config
+ retry: 3,
+ retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
+
+ // Select transformation
+ select: (data) => data.users.map(user => ({ ...user, fullName: `${user.firstName} ${user.lastName}` })),
+}
+```
+
+### 2. useMutation
+
+Dùng cho các tác vụ thay đổi data (POST, PUT, DELETE).
+
+```javascript
+// Cú pháp cơ bản
+const mutation = useMutation({
+ mutationFn: (variables) => axios.post('/api/data', variables),
+ onSuccess: (data, variables, context) => { /* success */ },
+ onError: (error, variables, context) => { /* error */ },
+ onSettled: (data, error, variables, context) => { /* luôn chạy */ },
+});
+
+// Ví dụ thực tế
+function CreateUserForm() {
+ const [name, setName] = useState('');
+
+ const createUserMutation = useMutation({
+ mutationFn: async (userData) => {
+ const response = await axios.post('/api/users', userData);
+ return response.data;
+ },
+ onSuccess: (newUser) => {
+ Alert.alert('Thành công', `Đã tạo user ${newUser.name}`);
+ queryClient.invalidateQueries({ queryKey: ['users'] }); // Refetch danh sách users
+ },
+ onError: (error) => {
+ Alert.alert('Lỗi', error.response?.data?.message || 'Không thể tạo user');
+ },
+ onMutate: async (newUser) => {
+ // Cancel ongoing queries
+ await queryClient.cancelQueries({ queryKey: ['users'] });
+
+ // Snapshot previous value
+ const previousUsers = queryClient.getQueryData(['users']);
+
+ // Optimistic update
+ queryClient.setQueryData(['users'], old => [...old, { id: 'temp', ...newUser }]);
+
+ return { previousUsers };
+ },
+ onError: (err, newUser, context) => {
+ // Rollback on error
+ queryClient.setQueryData(['users'], context.previousUsers);
+ },
+ });
+
+ const handleSubmit = () => {
+ createUserMutation.mutate({ name });
+ };
+
+ return (
+
+
+
+
+ );
+}
+```
+
+### 3. useInfiniteQuery
+
+Dùng cho pagination/scroll infinitive.
+
+```javascript
+function InfinitePostList() {
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading,
+ refetch
+ } = useInfiniteQuery({
+ queryKey: ['posts'],
+ queryFn: async ({ pageParam = 1 }) => {
+ const response = await axios.get('/api/posts', {
+ params: { page: pageParam, limit: 10 }
+ });
+ return {
+ data: response.data,
+ nextPage: pageParam + 1,
+ hasMore: response.data.length === 10
+ };
+ },
+ getNextPageParam: (lastPage) =>
+ lastPage.hasMore ? lastPage.nextPage : undefined,
+ staleTime: 1000 * 60 * 2, // 2 phút
+ });
+
+ const posts = data?.pages.flatMap(page => page.data) || [];
+
+ return (
+ }
+ keyExtractor={item => item.id}
+ onEndReached={() => hasNextPage && fetchNextPage()}
+ onEndReachedThreshold={0.5}
+ ListFooterComponent={() =>
+ isFetchingNextPage ? : null
+ }
+ refreshing={isLoading}
+ onRefresh={refetch}
+ />
+ );
+}
+```
+
+### 4. useQueries
+
+Fetch nhiều queries song song.
+
+```javascript
+function Dashboard() {
+ const queries = useQueries({
+ queries: [
+ {
+ queryKey: ['users'],
+ queryFn: () => axios.get('/api/users'),
+ staleTime: 1000 * 60 * 5,
+ },
+ {
+ queryKey: ['posts'],
+ queryFn: () => axios.get('/api/posts'),
+ staleTime: 1000 * 60 * 2,
+ },
+ {
+ queryKey: ['comments'],
+ queryFn: () => axios.get('/api/comments'),
+ staleTime: 1000 * 60 * 10,
+ },
+ ]
+ });
+
+ const [usersQuery, postsQuery, commentsQuery] = queries;
+
+ const isLoading = queries.some(query => query.isLoading);
+ const isError = queries.some(query => query.isError);
+
+ if (isLoading) return ;
+ if (isError) return Lỗi tải dữ liệu;
+
+ return (
+
+ Users: {usersQuery.data?.data?.length || 0}
+ Posts: {postsQuery.data?.data?.length || 0}
+ Comments: {commentsQuery.data?.data?.length || 0}
+
+ );
+}
+```
+
+### 5. useQueryClient
+
+Access đến QueryClient instance để quản lý queries.
+
+```javascript
+function MyComponent() {
+ const queryClient = useQueryClient();
+
+ const handleRefresh = () => {
+ // Refetch tất cả queries
+ queryClient.refetchQueries();
+
+ // Refetch queries cụ thể
+ queryClient.refetchQueries({ queryKey: ['users'] });
+
+ // Prefetch data
+ queryClient.prefetchQuery({
+ queryKey: ['posts'],
+ queryFn: fetchPosts,
+ staleTime: 1000 * 60 * 5
+ });
+ };
+
+ return ;
+}
+```
+
+### 6. invalidateQueries
+
+Mark queries như stale để trigger refetch.
+
+```javascript
+// Sau khi create/update/delete data
+mutationOptions = {
+ onSuccess: () => {
+ // Invalidate tất cả queries bắt đầu với 'posts'
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
+
+ // Invalidate exact query
+ queryClient.invalidateQueries({
+ queryKey: ['posts', { page: 1 }]
+ });
+
+ // Invalidate với predicate
+ queryClient.invalidateQueries({
+ predicate: (query) =>
+ query.queryKey[0] === 'posts' &&
+ query.queryKey[1]?.status === 'published'
+ });
+ }
+};
+```
+
+### 7. setQueryData / getQueryData
+
+Direct access/manipulate cache.
+
+```javascript
+// Get data từ cache
+const cachedUsers = queryClient.getQueryData(['users']);
+
+// Set data vào cache
+queryClient.setQueryData(['users'], newData);
+
+// Update một item trong cache
+queryClient.setQueryData(['users'], oldUsers =>
+ oldUsers.map(user =>
+ user.id === updatedUser.id ? updatedUser : user
+ )
+);
+```
+
+### 8. cancelQueries
+
+Cancel ongoing queries.
+
+```javascript
+// Cancel tất cả queries
+queryClient.cancelQueries();
+
+// Cancel queries cụ thể
+queryClient.cancelQueries({ queryKey: ['posts'] });
+
+// Cancel queries với predicate
+queryClient.cancelQueries({
+ predicate: (query) => query.queryKey[0] === 'posts'
+});
+```
+
+### 9. useIsFetching / useIsMutating
+
+Check if any queries/mutations are running.
+
+```javascript
+function GlobalLoadingIndicator() {
+ const isFetching = useIsFetching();
+ const isMutating = useIsMutating();
+
+ if (isFetching || isMutating) {
+ return (
+
+
+ Đang tải...
+
+ );
+ }
+
+ return null;
+}
+```
+
+---
+
+## Kết hợp React Query + Axios
+
+### 1. Tạo Axios Client với Interceptors
+
+```javascript
+// src/api/axiosClient.js
+import axios from 'axios';
+
+const axiosClient = axios.create({
+ baseURL: 'https://your-api.com/api',
+ timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Request interceptor - Add token
+axiosClient.interceptors.request.use(
+ (config) => {
+ const token = AsyncStorage.getItem('access_token');
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ }
+);
+
+// Response interceptor - Handle errors & refresh token
+let isRefreshing = false;
+let failedQueue = [];
+
+const processQueue = (error, token = null) => {
+ failedQueue.forEach((prom) => {
+ if (error) {
+ prom.reject(error);
+ } else {
+ prom.resolve(token);
+ }
+ });
+
+ failedQueue = [];
+};
+
+axiosClient.interceptors.response.use(
+ (response) => response,
+ async (error) => {
+ const originalRequest = error.config;
+
+ if (error.response?.status === 401 && !originalRequest._retry) {
+ if (isRefreshing) {
+ return new Promise((resolve, reject) => {
+ failedQueue.push({ resolve, reject });
+ }).then((token) => {
+ originalRequest.headers.Authorization = `Bearer ${token}`;
+ return axiosClient(originalRequest);
+ });
+ }
+
+ originalRequest._retry = true;
+ isRefreshing = true;
+
+ try {
+ const refreshToken = await AsyncStorage.getItem('refresh_token');
+ const response = await axios.post('/auth/refresh', {
+ refreshToken
+ });
+
+ const { accessToken } = response.data;
+ await AsyncStorage.setItem('access_token', accessToken);
+
+ axiosClient.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
+
+ processQueue(null, accessToken);
+
+ return axiosClient(originalRequest);
+ } catch (refreshError) {
+ processQueue(refreshError, null);
+ await AsyncStorage.multiRemove(['access_token', 'refresh_token']);
+
+ // Navigate to login
+ navigation.navigate('Login');
+
+ return Promise.reject(refreshError);
+ } finally {
+ isRefreshing = false;
+ }
+ }
+
+ return Promise.reject(error);
+ }
+);
+
+export default axiosClient;
+```
+
+### 2. Tạo API Service
+
+```javascript
+// src/api/userApi.js
+import axiosClient from './axiosClient';
+
+export const userApi = {
+ // Get all users
+ getUsers: (params = {}) => {
+ return axiosClient.get('/users', { params });
+ },
+
+ // Get user by ID
+ getUserById: (id) => {
+ return axiosClient.get(`/users/${id}`);
+ },
+
+ // Create user
+ createUser: (userData) => {
+ return axiosClient.post('/users', userData);
+ },
+
+ // Update user
+ updateUser: (id, userData) => {
+ return axiosClient.put(`/users/${id}`, userData);
+ },
+
+ // Delete user
+ deleteUser: (id) => {
+ return axiosClient.delete(`/users/${id}`);
+ },
+
+ // Upload avatar
+ uploadAvatar: (id, formData) => {
+ return axiosClient.post(`/users/${id}/avatar`, formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+ },
+};
+```
+
+### 3. Custom Hooks
+
+```javascript
+// src/hooks/useUser.js
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { userApi } from '../api/userApi';
+
+export const useUsers = (params = {}, options = {}) => {
+ return useQuery({
+ queryKey: ['users', params],
+ queryFn: () => userApi.getUsers(params),
+ staleTime: 1000 * 60 * 5, // 5 phút
+ ...options,
+ });
+};
+
+export const useUser = (id, options = {}) => {
+ return useQuery({
+ queryKey: ['user', id],
+ queryFn: () => userApi.getUserById(id),
+ enabled: !!id, // Chỉ fetch khi có id
+ staleTime: 1000 * 60 * 10, // 10 phút
+ ...options,
+ });
+};
+
+export const useCreateUser = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: userApi.createUser,
+ onSuccess: (newUser) => {
+ // Add new user to cache
+ queryClient.setQueryData(['users'], (old) => [...(old || []), newUser]);
+
+ // Invalidate để refetch nếu cần
+ queryClient.invalidateQueries({ queryKey: ['users'] });
+ },
+ onError: (error) => {
+ console.error('Create user error:', error);
+ },
+ });
+};
+
+export const useUpdateUser = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ id, userData }) => userApi.updateUser(id, userData),
+ onMutate: async ({ id, userData }) => {
+ // Cancel ongoing queries
+ await queryClient.cancelQueries({ queryKey: ['user', id] });
+
+ // Snapshot previous value
+ const previousUser = queryClient.getQueryData(['user', id]);
+
+ // Optimistic update
+ queryClient.setQueryData(['user', id], (old) => ({ ...old, ...userData }));
+
+ return { previousUser };
+ },
+ onError: (error, variables, context) => {
+ // Rollback on error
+ queryClient.setQueryData(['user', variables.id], context.previousUser);
+ },
+ onSettled: (data, error, variables) => {
+ // Refetch để ensure data consistency
+ queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
+ },
+ });
+};
+
+export const useDeleteUser = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: userApi.deleteUser,
+ onSuccess: (_, deletedId) => {
+ // Remove from cache
+ queryClient.setQueryData(['users'], (old) =>
+ old.filter(user => user.id !== deletedId)
+ );
+ },
+ });
+};
+```
+
+---
+
+## Ví dụ đầy đủ cho React Native + Expo
+
+### 1. App.js - Setup QueryClientProvider
+
+```javascript
+// App.js
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { NavigationContainer } from '@react-navigation/native';
+import { createNativeStackNavigator } from '@react-navigation/native-stack';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+import LoginScreen from './screens/LoginScreen';
+import HomeScreen from './screens/HomeScreen';
+
+// Create QueryClient
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60, // 1 phút
+ cacheTime: 1000 * 60 * 5, // 5 phút
+ retry: 3,
+ refetchOnWindowFocus: false, // Disable cho React Native
+ },
+ mutations: {
+ retry: 1,
+ },
+ },
+});
+
+const Stack = createNativeStackNavigator();
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+### 2. LoginScreen.js
+
+```javascript
+// screens/LoginScreen.js
+import React, { useState } from 'react';
+import { View, Text, TextInput, Button, Alert, StyleSheet } from 'react-native';
+import { useMutation } from '@tanstack/react-query';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { useNavigation } from '@react-navigation/native';
+
+import { authApi } from '../api/authApi';
+
+export default function LoginScreen() {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const navigation = useNavigation();
+
+ const loginMutation = useMutation({
+ mutationFn: (credentials) => authApi.login(credentials),
+ onSuccess: async (data) => {
+ // Save tokens
+ await AsyncStorage.multiSet([
+ ['access_token', data.accessToken],
+ ['refresh_token', data.refreshToken],
+ ['user_info', JSON.stringify(data.user)],
+ ]);
+
+ Alert.alert('Thành công', 'Đăng nhập thành công!');
+ navigation.replace('Home');
+ },
+ onError: (error) => {
+ Alert.alert('Lỗi', error.response?.data?.message || 'Đăng nhập thất bại');
+ },
+ });
+
+ const handleLogin = () => {
+ if (!email || !password) {
+ Alert.alert('Lỗi', 'Vui lòng nhập email và mật khẩu');
+ return;
+ }
+
+ loginMutation.mutate({ email, password });
+ };
+
+ return (
+
+ Đăng nhập
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: 20,
+ justifyContent: 'center',
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ marginBottom: 30,
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ padding: 15,
+ borderRadius: 8,
+ marginBottom: 15,
+ fontSize: 16,
+ },
+});
+```
+
+### 3. HomeScreen.js - User list with pull to refresh
+
+```javascript
+// screens/HomeScreen.js
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ FlatList,
+ TouchableOpacity,
+ RefreshControl,
+ StyleSheet,
+ ActivityIndicator
+} from 'react-native';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useNavigation } from '@react-navigation/native';
+
+import { userApi } from '../api/userApi';
+import { useUsers, useDeleteUser } from '../hooks/useUser';
+
+export default function HomeScreen() {
+ const navigation = useNavigation();
+ const [page, setPage] = useState(1);
+ const queryClient = useQueryClient();
+
+ const {
+ data: usersData,
+ isLoading,
+ error,
+ refetch,
+ isFetching,
+ } = useUsers({ page });
+
+ const deleteUserMutation = useDeleteUser();
+
+ const handleDeleteUser = (userId) => {
+ Alert.alert(
+ 'Xác nhận',
+ 'Bạn có chắc muốn xóa user này?',
+ [
+ { text: 'Hủy', style: 'cancel' },
+ {
+ text: 'Xóa',
+ onPress: () => deleteUserMutation.mutate(userId),
+ style: 'destructive'
+ }
+ ]
+ );
+ };
+
+ const renderUserItem = ({ item }) => (
+ navigation.navigate('UserDetail', { userId: item.id })}
+ >
+
+ {item.name}
+ {item.email}
+
+
+ handleDeleteUser(item.id)}
+ disabled={deleteUserMutation.isLoading}
+ >
+ Xóa
+
+
+ );
+
+ const handleLoadMore = () => {
+ if (!isLoading && !isFetching) {
+ setPage(prev => prev + 1);
+ }
+ };
+
+ if (isLoading && page === 1) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ Lỗi: {error.message}
+ refetch()}>
+ Thử lại
+
+
+ );
+ }
+
+ return (
+
+ item.id.toString()}
+ contentContainerStyle={styles.listContainer}
+ refreshControl={
+
+ }
+ onEndReached={handleLoadMore}
+ onEndReachedThreshold={0.5}
+ ListFooterComponent={() =>
+ isFetching && page > 1 ? (
+
+ ) : null
+ }
+ ListEmptyComponent={() => (
+
+ Không có user nào
+
+ )}
+ />
+
+ navigation.navigate('CreateUser')}
+ >
+ + Thêm User
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ center: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ listContainer: {
+ padding: 15,
+ },
+ userItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'white',
+ padding: 15,
+ borderRadius: 8,
+ marginBottom: 10,
+ elevation: 2,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.2,
+ shadowRadius: 1.41,
+ },
+ userInfo: {
+ flex: 1,
+ },
+ userName: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 5,
+ },
+ userEmail: {
+ fontSize: 14,
+ color: '#666',
+ },
+ deleteButton: {
+ backgroundColor: '#ff4757',
+ paddingHorizontal: 15,
+ paddingVertical: 8,
+ borderRadius: 5,
+ },
+ deleteButtonText: {
+ color: 'white',
+ fontWeight: '600',
+ },
+ errorText: {
+ fontSize: 16,
+ color: '#ff4757',
+ marginBottom: 20,
+ },
+ retryButton: {
+ backgroundColor: '#3742fa',
+ paddingHorizontal: 20,
+ paddingVertical: 10,
+ borderRadius: 5,
+ },
+ retryButtonText: {
+ color: 'white',
+ fontWeight: '600',
+ },
+ footerLoader: {
+ padding: 20,
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ paddingVertical: 50,
+ },
+ addButton: {
+ position: 'absolute',
+ bottom: 30,
+ right: 30,
+ backgroundColor: '#3742fa',
+ paddingHorizontal: 20,
+ paddingVertical: 15,
+ borderRadius: 30,
+ elevation: 5,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 3.84,
+ },
+ addButtonText: {
+ color: 'white',
+ fontWeight: '600',
+ fontSize: 16,
+ },
+});
+```
+
+### 4. CreateUserScreen.js
+
+```javascript
+// screens/CreateUserScreen.js
+import React, { useState } from 'react';
+import {
+ View,
+ Text,
+ TextInput,
+ Button,
+ StyleSheet,
+ Alert,
+ ScrollView
+} from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import { useCreateUser } from '../hooks/useUser';
+
+export default function CreateUserScreen() {
+ const navigation = useNavigation();
+ const [formData, setFormData] = useState({
+ name: '',
+ email: '',
+ phone: '',
+ address: '',
+ });
+
+ const createUserMutation = useCreateUser();
+
+ const handleInputChange = (field, value) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleSubmit = () => {
+ // Validate
+ if (!formData.name.trim()) {
+ Alert.alert('Lỗi', 'Vui lòng nhập tên');
+ return;
+ }
+
+ if (!formData.email.trim()) {
+ Alert.alert('Lỗi', 'Vui lòng nhập email');
+ return;
+ }
+
+ // Simple email validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(formData.email)) {
+ Alert.alert('Lỗi', 'Email không hợp lệ');
+ return;
+ }
+
+ createUserMutation.mutate(formData, {
+ onSuccess: () => {
+ Alert.alert('Thành công', 'Đã tạo user mới!');
+ navigation.goBack();
+ },
+ });
+ };
+
+ return (
+
+ Tạo User Mới
+
+
+ Tên *
+ handleInputChange('name', value)}
+ placeholder="Nhập tên"
+ />
+
+
+
+ Email *
+ handleInputChange('email', value)}
+ placeholder="Nhập email"
+ keyboardType="email-address"
+ autoCapitalize="none"
+ />
+
+
+
+ Số điện thoại
+ handleInputChange('phone', value)}
+ placeholder="Nhập số điện thoại"
+ keyboardType="phone-pad"
+ />
+
+
+
+ Địa chỉ
+ handleInputChange('address', value)}
+ placeholder="Nhập địa chỉ"
+ multiline
+ numberOfLines={3}
+ />
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f5f5f5',
+ },
+ contentContainer: {
+ padding: 20,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ textAlign: 'center',
+ marginBottom: 30,
+ },
+ formGroup: {
+ marginBottom: 20,
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 8,
+ color: '#333',
+ },
+ input: {
+ borderWidth: 1,
+ borderColor: '#ddd',
+ borderRadius: 8,
+ padding: 15,
+ fontSize: 16,
+ backgroundColor: 'white',
+ },
+ textArea: {
+ height: 80,
+ textAlignVertical: 'top',
+ },
+ buttonGroup: {
+ marginTop: 30,
+ gap: 15,
+ },
+});
+```
+
+---
+
+## Tối ưu Performance cho React Native
+
+### 1. Cấu hình StaleTime/CacheTime hiệu quả
+
+```javascript
+// Quy tắc chung cho React Native
+const queryConfig = {
+ // Data ít thay đổi (user profile, settings)
+ userProfile: { staleTime: 1000 * 60 * 30, cacheTime: 1000 * 60 * 60 }, // 30m stale, 1h cache
+
+ // Data thay đổi vừa phải (posts, comments)
+ posts: { staleTime: 1000 * 60 * 5, cacheTime: 1000 * 60 * 15 }, // 5m stale, 15m cache
+
+ // Data realtime (notifications, chat)
+ notifications: { staleTime: 0, cacheTime: 1000 * 60 * 2 }, // 0 stale, 2m cache
+
+ // Data tĩnh (categories, options)
+ categories: { staleTime: Infinity, cacheTime: Infinity }, // Never stale
+};
+
+// Sử dụng
+useQuery({
+ queryKey: ['user', 'profile'],
+ queryFn: fetchUserProfile,
+ ...queryConfig.userProfile,
+});
+```
+
+### 2. Tránh gọi API lại khi đổi tab
+
+```javascript
+// Sử dụng keepPreviousData cho pagination
+const { data, isPreviousData } = useQuery({
+ queryKey: ['posts', page],
+ queryFn: () => fetchPosts(page),
+ keepPreviousData: true, // Giữ data cũ khi loading
+ staleTime: 1000 * 60 * 5,
+});
+
+// Focus refetching - chỉ refetch khi cần
+useQuery({
+ queryKey: ['notifications'],
+ queryFn: fetchNotifications,
+ refetchOnWindowFocus: false, // Disable auto refetch
+ refetchInterval: 1000 * 30, // Refetch mỗi 30s cho data realtime
+});
+
+// Prefetch data khi hover vào tab (React Navigation)
+useFocusEffect(
+ React.useCallback(() => {
+ queryClient.prefetchQuery({
+ queryKey: ['posts'],
+ queryFn: fetchPosts,
+ });
+ }, [queryClient])
+);
+```
+
+### 3. Pull to Refresh & Infinite Scroll
+
+```javascript
+function PostList() {
+ const {
+ data,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch,
+ isRefetching,
+ } = useInfiniteQuery({
+ queryKey: ['posts'],
+ queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
+ getNextPageParam: (lastPage) => lastPage.nextPage,
+ staleTime: 1000 * 60 * 2, // 2 phút
+ refetchOnWindowFocus: false,
+ });
+
+ const posts = React.useMemo(() =>
+ data?.pages.flatMap(page => page.posts) || [],
+ [data]
+ );
+
+ const renderItem = React.useCallback(({ item }) =>
+ ,
+ []
+ );
+
+ const keyExtractor = React.useCallback(item => item.id.toString(), []);
+
+ return (
+
+ }
+ // Infinite scroll
+ onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
+ onEndReachedThreshold={0.5}
+ ListFooterComponent={() =>
+ isFetchingNextPage ? : null
+ }
+ // Performance optimizations
+ removeClippedSubviews={true}
+ maxToRenderPerBatch={10}
+ windowSize={10}
+ initialNumToRender={10}
+ getItemLayout={(data, index) => ({
+ length: ITEM_HEIGHT,
+ offset: ITEM_HEIGHT * index,
+ index,
+ })}
+ />
+ );
+}
+```
+
+### 4. Memory Optimization
+
+```javascript
+// Clean up queries khi unmount
+useEffect(() => {
+ return () => {
+ // Cancel ongoing queries
+ queryClient.cancelQueries({ queryKey: ['posts'] });
+
+ // Remove queries khỏi cache khi không cần
+ queryClient.removeQueries({ queryKey: ['temp-data'] });
+ };
+}, [queryClient]);
+
+// Limit cache size
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ cacheTime: 1000 * 60 * 10, // 10 phút
+ staleTime: 1000 * 60, // 1 phút
+ },
+ },
+ // Config cache size limits
+ queryCache: new QueryCache({
+ onError: (error) => {
+ console.error('Query error:', error);
+ },
+ }),
+});
+
+//选择性 refetch
+const handleRefresh = () => {
+ // Chỉ refetch những queries cần thiết
+ queryClient.refetchQueries({
+ queryKey: ['critical-data'],
+ exact: false,
+ });
+};
+```
+
+### 5. Advanced Patterns
+
+```javascript
+// Dependent queries - query phụ thuộc vào query khác
+function UserProfile({ userId }) {
+ const { data: user } = useQuery({
+ queryKey: ['user', userId],
+ queryFn: () => fetchUser(userId),
+ enabled: !!userId,
+ });
+
+ const { data: posts } = useQuery({
+ queryKey: ['user-posts', userId],
+ queryFn: () => fetchUserPosts(userId),
+ enabled: !!user, // Chỉ chạy khi có user
+ });
+
+ // ...
+}
+
+// Paginated queries với state management
+function PaginatedList() {
+ const [page, setPage] = useState(1);
+ const [filters, setFilters] = useState({});
+
+ const { data, isLoading } = useQuery({
+ queryKey: ['items', page, filters],
+ queryFn: () => fetchItems(page, filters),
+ keepPreviousData: true,
+ });
+
+ const handleFilterChange = (newFilters) => {
+ setFilters(newFilters);
+ setPage(1); // Reset về trang 1
+ };
+
+ // ...
+}
+
+// Background updates cho real-time data
+function RealtimeComponent() {
+ useQuery({
+ queryKey: ['notifications'],
+ queryFn: fetchNotifications,
+ refetchInterval: 5000, // Refetch mỗi 5s
+ refetchIntervalInBackground: false, // Không refetch khi app ở background
+ staleTime: 0,
+ });
+}
+```
+
+### 6. Error Handling & Retry Strategies
+
+```javascript
+// Custom retry logic
+const retryConfig = {
+ retry: (failureCount, error) => {
+ // Không retry cho 4xx errors
+ if (error.response?.status >= 400 && error.response?.status < 500) {
+ return false;
+ }
+
+ // Retry tối đa 3 lần cho 5xx hoặc network errors
+ return failureCount < 3;
+ },
+ retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
+};
+
+// Global error boundary cho React Query
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ ...retryConfig,
+ onError: (error) => {
+ // Global error handling
+ if (error.response?.status === 401) {
+ // Handle auth error
+ navigation.navigate('Login');
+ }
+ },
+ },
+ mutations: {
+ ...retryConfig,
+ },
+ },
+});
+```
+
+---
+
+## Kết luận
+
+React Query kết hợp với Axios tạo ra một giải pháp mạnh mẽ cho việc quản lý API state trong React Native:
+
+1. **Performance**: Caching thông minh reduce unnecessary API calls
+2. **User Experience**: Instant feedback với optimistic updates
+3. **Developer Experience**: Code sạch, dễ maintain với separation of concerns
+4. **Reliability**: Automatic retry, error handling, background updates
+5. **Scalability**: Dễ scale khi ứng dụng phức tạp hơn
+
+**Best practices:**
+
+- Luôn đặt `staleTime` phù hợp với từng loại data
+- Sử dụng `enabled` cho conditional queries
+- Implement optimistic updates cho better UX
+- Clean up queries khi unmount
+- Use React.memo và useMemo cho performance optimization
+
+Happy coding! 🚀
\ No newline at end of file
diff --git a/app/(tabs)/warning.tsx b/app/(tabs)/warning.tsx
index 5d7f167..525a98d 100644
--- a/app/(tabs)/warning.tsx
+++ b/app/(tabs)/warning.tsx
@@ -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({
+ offset: 0,
+ limit: PAGE_SIZE,
+ order: "time",
+ dir: "desc",
+ });
+ const [alarms, setAlarms] = useState([]);
+ 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 (
-
-
- {/* Icon Container */}
-
-
-
+ 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 */}
-
- {/* Header: Ship name + Badge */}
-
-
- {alarm.ship_name || alarm.thing_id}
-
-
-
- {config.label}
-
-
-
+ 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 */}
-
- {alarm.zone.message || alarm.zone.zone_name}
-
+ const resp = await queryAlarms({
+ ...usedParams,
+ offset: nextOffset,
+ });
+ const slice = resp.data?.alarms ?? [];
- {/* Footer: Zone ID + Time */}
-
-
-
-
- {formatTimestamp(alarm.zone.gps_time)}
-
-
-
-
-
-
- );
-});
+ 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 }) => (
- handleAlarmPress(item)} />
+ ({ item }: { item: Model.Alarm }) => (
+
),
- [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(() => , []);
+ 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);
- }, [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 (
{/* Header */}
-
-
-
-
+
Cảnh báo
-
-
- {displayAlarms.length}
-
+
+
+
+
- {/* Stats Bar */}
-
- {(["entered", "approaching", "fishing"] as AlarmType[]).map(
- (type) => {
- const config = ALARM_CONFIG[type];
- const count = alarmCounts[type] || 0;
- return (
-
-
-
- {count}
-
-
- );
- }
- )}
-
+ {/* Search Form */}
+ {isShowSearchForm && (
+
+
+
+ )}
{/* Alarm List */}
-
+ {alarms.length > 0 ? (
+
+
+ Đang tải...
+
+ ) : null
+ }
+ />
+ ) : (
+
+
+
+ Không có cảnh báo nào
+
+
+ )}
);
-}
+};
+
+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",
},
});
diff --git a/components/ButtonCancelTrip.tsx b/components/ButtonCancelTrip.tsx
deleted file mode 100644
index 0fb675b..0000000
--- a/components/ButtonCancelTrip.tsx
+++ /dev/null
@@ -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 = ({
- title,
- onPress,
-}) => {
- const { t } = useI18n();
- const displayTitle = title || t("trip.buttonCancelTrip.title");
- return (
-
- {displayTitle}
-
- );
-};
-
-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;
diff --git a/components/ButtonCreateNewHaulOrTrip.tsx b/components/ButtonCreateNewHaulOrTrip.tsx
deleted file mode 100644
index 788e174..0000000
--- a/components/ButtonCreateNewHaulOrTrip.tsx
+++ /dev/null
@@ -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 = ({
- 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 (
-
- {trip?.trip_status === 2 ? (
- }
- type="primary"
- style={{ backgroundColor: "green", borderRadius: 10 }}
- onPress={async () => handleStartTrip(3)}
- >
- {t("trip.startTrip")}
-
- ) : checkHaulFinished() ? (
- }
- type="primary"
- style={{ borderRadius: 10 }}
- onPress={() => setIsFinishHaulModalOpen(true)}
- >
- {t("trip.endHaul")}
-
- ) : (
- }
- type="primary"
- style={{ borderRadius: 10 }}
- onPress={async () => {
- createNewHaul();
- }}
- >
- {t("trip.startHaul")}
-
- )}
- f.status === 0)!}
- fishingLogIndex={trip?.fishing_logs?.length!}
- isVisible={isFinishHaulModalOpen}
- onClose={function (): void {
- setIsFinishHaulModalOpen(false);
- }}
- />
-
- );
-};
-
-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;
diff --git a/components/ButtonEndTrip.tsx b/components/ButtonEndTrip.tsx
deleted file mode 100644
index 6e7aa69..0000000
--- a/components/ButtonEndTrip.tsx
+++ /dev/null
@@ -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 = ({ title, onPress }) => {
- const { t } = useI18n();
- const displayTitle = title || t("trip.buttonEndTrip.title");
- return (
-
- {displayTitle}
-
- );
-};
-
-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;
diff --git a/components/alarm/AlarmCard.tsx b/components/alarm/AlarmCard.tsx
new file mode 100644
index 0000000..2050423
--- /dev/null
+++ b/components/alarm/AlarmCard.tsx
@@ -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 = ({ 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 (
+
+
+ {/* Left Side - Icon and Content */}
+
+ {/* Icon */}
+
+
+
+
+ {/* Title and Info */}
+
+ {/* Name */}
+
+
+ {alarm.name || alarm.thing_name || "Unknown"}
+
+
+
+ {/* Location (thing_name) and Time */}
+
+
+
+ Trạm
+
+
+ {alarm.thing_name || "Unknown"}
+
+
+
+
+ Thời gian
+
+
+ {formatDate(alarm.time)}
+
+
+
+
+ {/* Status Badge */}
+ handlePress(alarm)}
+ activeOpacity={0.7}
+ >
+
+
+ {alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
+
+
+
+
+
+
+ {alarm.confirmed && (
+
+
+
+ )}
+
+
+ setShowModal(false)}
+ >
+
+
+
+ Nhập ghi chú xác nhận
+
+
+
+ {
+ setShowModal(false);
+ setNote("");
+ }}
+ disabled={submitting}
+ >
+ Hủy
+
+ submitConfirm("confirm")}
+ disabled={submitting || !canSubmit}
+ >
+ {submitting ? (
+
+ ) : (
+ Xác nhận
+ )}
+
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/components/alarm/AlarmSearchForm.tsx b/components/alarm/AlarmSearchForm.tsx
new file mode 100644
index 0000000..195aae0
--- /dev/null
+++ b/components/alarm/AlarmSearchForm.tsx
@@ -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 = ({
+ 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({
+ 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 (
+
+
+ {/* Search Input */}
+ (
+
+ Tìm kiếm
+
+
+ {value ? (
+ onChange("")}
+ style={styles.clearButton}
+ >
+
+
+ ) : null}
+
+
+ )}
+ />
+
+ {/* Level and Confirmed Selects */}
+
+
+ (
+
+ Mức độ
+
+
+ )}
+ />
+
+
+
+ (
+
+ Trạng thái
+
+
+ )}
+ />
+
+
+
+ {/* Action Buttons */}
+
+
+
+ Đặt lại
+
+
+
+
+
+ Tìm kiếm
+
+
+
+
+
+ );
+};
+
+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;
diff --git a/components/alarm/WarningCard.tsx b/components/alarm/WarningCard.tsx
deleted file mode 100644
index 39ca756..0000000
--- a/components/alarm/WarningCard.tsx
+++ /dev/null
@@ -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 (
- }
- renderItem={({ item }) => (
- 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 (
-
-
- {/* Left content */}
-
- {/* Icon */}
-
-
-
-
- {/* Info */}
-
- {/* Code */}
-
- {alarm.code}
-
-
- {/* Title */}
-
- {alarm.title}
-
-
- {/* Station and Time */}
-
-
- Trạm
- {alarm.station}
-
-
- Thời gian
-
- {formatTimestamp(alarm.timestamp)}
-
-
-
-
- {/* Status Badge */}
- {/*
-
-
- {statusConfig.label}
-
-
- */}
-
-
-
- {/* Checkmark for confirmed */}
- {/* {alarm.status === "confirmed" && (
-
-
-
- )} */}
-
-
- );
-};
-
-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;
diff --git a/components/map/SosButton.tsx b/components/map/SosButton.tsx
deleted file mode 100644
index ee35d21..0000000
--- a/components/map/SosButton.tsx
+++ /dev/null
@@ -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();
- const [showConfirmSosDialog, setShowConfirmSosDialog] = useState(false);
- const [selectedSosMessage, setSelectedSosMessage] = useState(
- 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 (
- <>
- }
- type="danger"
- size="middle"
- onPress={() => handleClickButton(sosData?.active || false)}
- style={{ borderRadius: 20 }}
- >
- {sosData?.active ? t("home.sos.active") : t("home.sos.inactive")}
-
- {
- 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 */}
-
- {t("home.sos.content")}
-
-
-
- {/* Input Custom Message nếu chọn "Khác" */}
- {selectedSosMessage === 999 && (
-
- {t("home.sos.statusInput")}
- {
- setCustomMessage(text);
- if (text.trim() !== "") {
- setErrors((prev) => {
- const newErrors = { ...prev };
- delete newErrors.customMessage;
- return newErrors;
- });
- }
- }}
- multiline
- numberOfLines={4}
- />
- {errors.customMessage && (
- {errors.customMessage}
- )}
-
- )}
-
- >
- );
-};
-
-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;
diff --git a/constants/index.ts b/constants/index.ts
index a6ef37d..31bda61 100644
--- a/constants/index.ts
+++ b/constants/index.ts
@@ -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";
diff --git a/controller/AlarmController.ts b/controller/AlarmController.ts
new file mode 100644
index 0000000..1ec5a3c
--- /dev/null
+++ b/controller/AlarmController.ts
@@ -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(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 });
+}
diff --git a/controller/DeviceController.ts b/controller/DeviceController.ts
index cc15da6..ebffbfc 100644
--- a/controller/DeviceController.ts
+++ b/controller/DeviceController.ts
@@ -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(API_GET_GPS);
-}
-
-export async function queryAlarm() {
- return api.get(API_GET_ALARMS);
-}
-
-export async function queryTrackPoints() {
- return api.get(API_PATH_SHIP_TRACK_POINTS);
-}
-
-export async function queryEntities(): Promise {
- const response = await api.get(API_PATH_ENTITIES);
- return response.data.map(transformEntityResponse);
-}
-
-export async function queryGetSos() {
- return await api.get(API_SOS);
-}
-export async function queryDeleteSos() {
- return await api.delete(API_SOS);
-}
-
-export async function querySendSosMessage(message: string) {
- return await api.put(API_SOS, { message });
-}
export async function querySearchThings(body: Model.SearchThingBody) {
return await api.post(API_PATH_SEARCH_THINGS, body);
diff --git a/controller/typings.d.ts b/controller/typings.d.ts
index 7b6543e..2a4ca9c 100644
--- a/controller/typings.d.ts
+++ b/controller/typings.d.ts
@@ -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;
+ }
}
diff --git a/services/device_events.ts b/services/device_events.ts
index 0fbef1a..08c9595 100644
--- a/services/device_events.ts
+++ b/services/device_events.ts
@@ -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 | null;
- alarm: ReturnType | null;
- entities: ReturnType | null;
- trackPoints: ReturnType | null;
banzones: ReturnType | null;
searchThings: ReturnType | 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();
}