complated project
This commit is contained in:
407
screens/profile/ui/NotificationTab.tsx
Normal file
407
screens/profile/ui/NotificationTab.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import { useTheme } from '@/components/ThemeContext';
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { ArrowLeft } from 'lucide-react-native';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
FlatList,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { user_api } from '../lib/api';
|
||||
import { NotificationListDataRes } from '../lib/type';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function NotificationTab() {
|
||||
const { isDark } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ['notifications-list'],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const response = await user_api.notification_list({
|
||||
page: pageParam,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.current_page < lastPage.total_pages ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
const notifications = data?.pages.flatMap((p) => p.results) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<View style={styles.loadingContent}>
|
||||
<ActivityIndicator size="large" color="#3b82f6" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<View style={styles.errorContent}>
|
||||
<Text style={styles.errorTitle}>{t('Xatolik yuz berdi')}</Text>
|
||||
<Text style={styles.errorMessage}>{t("Bildirishnomalarni yuklashda muammo bo'ldi")}</Text>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={() => refetch()}>
|
||||
<Text style={styles.retryButtonText}>{t('Qayta urinish')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.header, { backgroundColor: isDark ? '#0f172a' : '#fff' }]}>
|
||||
<Pressable onPress={() => router.push('/profile')}>
|
||||
<ArrowLeft color={isDark ? '#fff' : '#0f172a'} />
|
||||
</Pressable>
|
||||
|
||||
<Text style={[styles.headerTitle, { color: isDark ? '#fff' : '#0f172a' }]}>
|
||||
{t('Bildirishnomalar')}
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={notifications}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item, index }) => <NotificationCard item={item} />}
|
||||
onEndReached={() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onEndReachedThreshold={0.5}
|
||||
ListFooterComponent={
|
||||
isFetchingNextPage ? (
|
||||
<ActivityIndicator size="small" color="#3b82f6" style={{ marginVertical: 20 }} />
|
||||
) : null
|
||||
}
|
||||
refreshing={isLoading}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- CARD ---------------- */
|
||||
|
||||
function NotificationCard({ item }: { item: NotificationListDataRes }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [scaleAnim] = useState(new Animated.Value(1));
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: (id: number) => user_api.is_ready_id(id),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ['notification-list'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handlePressIn = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 0.96,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
Animated.spring(scaleAnim, {
|
||||
toValue: 1,
|
||||
friction: 4,
|
||||
tension: 50,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
};
|
||||
|
||||
const handlePress = (id: number) => {
|
||||
if (!item.is_read) {
|
||||
mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: [{ scale: scaleAnim }],
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onPress={() => handlePress(item.id)}
|
||||
style={[styles.card, !item.is_read && styles.unreadCard]}
|
||||
>
|
||||
<View style={styles.cardContent}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={[styles.cardTitle, !item.is_read && styles.unreadTitle]} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{!item.is_read && <View style={styles.unreadIndicator} />}
|
||||
</View>
|
||||
|
||||
<Text style={styles.cardMessage} numberOfLines={2}>
|
||||
{item.description}
|
||||
</Text>
|
||||
|
||||
<Text style={styles.cardTime}>{formatDate(item.created_at, t)}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- HELPERS ---------------- */
|
||||
|
||||
function formatDate(date: string, t: any) {
|
||||
const now = new Date();
|
||||
const notifDate = new Date(date);
|
||||
const diffMs = now.getTime() - notifDate.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Hozir';
|
||||
if (diffMins < 60) return `${diffMins} ${t('daqiqa oldin')}`;
|
||||
if (diffHours < 24) return `${diffHours} ${t('soat oldin')}`;
|
||||
if (diffDays < 7) return `${diffDays} ${t('kun oldin')}`;
|
||||
|
||||
return notifDate.toLocaleDateString('uz-UZ', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- STYLES ---------------- */
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0c17',
|
||||
},
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
|
||||
/* Card Styles */
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#121826',
|
||||
padding: 16,
|
||||
borderRadius: 24,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(60, 70, 90, 0.18)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 8 },
|
||||
shadowOpacity: 0.35,
|
||||
shadowRadius: 16,
|
||||
elevation: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
unreadCard: {
|
||||
backgroundColor: '#1a2236',
|
||||
borderColor: 'rgba(59, 130, 246, 0.4)',
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 20,
|
||||
elevation: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(30, 38, 56, 0.7)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
unreadIconContainer: {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.22)',
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 32,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
cardTitle: {
|
||||
flex: 1,
|
||||
color: '#d1d5db',
|
||||
fontSize: 16.5,
|
||||
fontWeight: '600',
|
||||
letterSpacing: -0.1,
|
||||
},
|
||||
unreadTitle: {
|
||||
color: '#f1f5f9',
|
||||
fontWeight: '700',
|
||||
},
|
||||
unreadIndicator: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: '#3b82f6',
|
||||
marginLeft: 8,
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.7,
|
||||
shadowRadius: 6,
|
||||
},
|
||||
cardMessage: {
|
||||
color: '#9ca3af',
|
||||
fontSize: 14.5,
|
||||
lineHeight: 21,
|
||||
marginBottom: 8,
|
||||
},
|
||||
cardTime: {
|
||||
color: '#64748b',
|
||||
fontSize: 12.5,
|
||||
fontWeight: '500',
|
||||
opacity: 0.9,
|
||||
},
|
||||
|
||||
/* Loading State */
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0c17',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loadingContent: {
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
color: '#94a3b8',
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
/* Error State */
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0c17',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
errorContent: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#151b2e',
|
||||
padding: 32,
|
||||
borderRadius: 24,
|
||||
maxWidth: 320,
|
||||
},
|
||||
errorIconContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: '#1e2638',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: 40,
|
||||
},
|
||||
errorTitle: {
|
||||
color: '#ef4444',
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorMessage: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
lineHeight: 20,
|
||||
},
|
||||
retryButton: {
|
||||
backgroundColor: '#3b82f6',
|
||||
paddingHorizontal: 32,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#3b82f6',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
|
||||
/* Empty State */
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0c17',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: 'center',
|
||||
maxWidth: 300,
|
||||
},
|
||||
emptyIconContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: '#151b2e',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
borderWidth: 2,
|
||||
borderColor: '#1e2638',
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 50,
|
||||
},
|
||||
emptyTitle: {
|
||||
color: '#ffffff',
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyMessage: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 15,
|
||||
textAlign: 'center',
|
||||
lineHeight: 22,
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 10,
|
||||
elevation: 3,
|
||||
},
|
||||
headerTitle: { fontSize: 18, fontWeight: '700', lineHeight: 24 },
|
||||
});
|
||||
Reference in New Issue
Block a user