feat: redesign halaman notifikasi dengan icon berwarna, grouping tanggal, dan urutan unread-first
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import BorderBottomItemVertical from "@/components/borderBottomItemVertical";
|
import AppHeader from "@/components/AppHeader";
|
||||||
import SkeletonTwoItem from "@/components/skeletonTwoItem";
|
import SkeletonTwoItem from "@/components/skeletonTwoItem";
|
||||||
import Text from "@/components/Text";
|
import Text from "@/components/Text";
|
||||||
import { ColorsStatus } from "@/constants/ColorsStatus";
|
|
||||||
import Styles from "@/constants/Styles";
|
import Styles from "@/constants/Styles";
|
||||||
import { apiGetNotification, apiReadOneNotification } from "@/lib/api";
|
import { apiGetNotification, apiReadOneNotification } from "@/lib/api";
|
||||||
import { setUpdateNotification } from "@/lib/notificationSlice";
|
import { setUpdateNotification } from "@/lib/notificationSlice";
|
||||||
@@ -9,10 +8,12 @@ import { pushToPage } from "@/lib/pushToPage";
|
|||||||
import { useAuthSession } from "@/providers/AuthProvider";
|
import { useAuthSession } from "@/providers/AuthProvider";
|
||||||
import { useTheme } from "@/providers/ThemeProvider";
|
import { useTheme } from "@/providers/ThemeProvider";
|
||||||
import { Feather } from "@expo/vector-icons";
|
import { Feather } from "@expo/vector-icons";
|
||||||
|
import { router, Stack } from "expo-router";
|
||||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { RefreshControl, SafeAreaView, View, VirtualizedList } from "react-native";
|
import { FlatList, Pressable, RefreshControl, SafeAreaView, View } from "react-native";
|
||||||
import { useDispatch, useSelector } from "react-redux";
|
import { useDispatch, useSelector } from "react-redux";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string
|
id: string
|
||||||
@@ -24,6 +25,22 @@ type Props = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HeaderRow = { _type: 'header'; date: string }
|
||||||
|
type ItemRow = Props & { _type: 'item' }
|
||||||
|
type ListRow = HeaderRow | ItemRow
|
||||||
|
|
||||||
|
function getNotifStyle(category: string): { icon: keyof typeof Feather.glyphMap; color: string } {
|
||||||
|
if (category === 'announcement') return { icon: 'volume-2', color: '#3B82F6' }
|
||||||
|
if (category === 'project') return { icon: 'activity', color: '#10B981' }
|
||||||
|
if (category.includes('/task')) return { icon: 'check-circle', color: '#8B5CF6' }
|
||||||
|
if (category === 'division') return { icon: 'users', color: '#3B82F6' }
|
||||||
|
if (category.includes('/discussion') || category === 'discussion-general') return { icon: 'message-square', color: '#06B6D4' }
|
||||||
|
if (category.includes('/calendar')) return { icon: 'calendar', color: '#F59E0B' }
|
||||||
|
if (category.includes('/document')) return { icon: 'file-text', color: '#FBBF24' }
|
||||||
|
if (category === 'member') return { icon: 'user', color: '#1F3C88' }
|
||||||
|
return { icon: 'bell', color: '#6B7280' }
|
||||||
|
}
|
||||||
|
|
||||||
export default function Notification() {
|
export default function Notification() {
|
||||||
const { token, decryptToken } = useAuthSession()
|
const { token, decryptToken } = useAuthSession()
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
@@ -32,7 +49,6 @@ export default function Notification() {
|
|||||||
const updateNotification = useSelector((state: any) => state.notificationUpdate)
|
const updateNotification = useSelector((state: any) => state.notificationUpdate)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
// TanStack Query for Notifications with Infinite Scroll
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
@@ -55,12 +71,31 @@ export default function Notification() {
|
|||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Flatten pages into a single data array
|
|
||||||
const flatData = useMemo(() => {
|
const flatData = useMemo(() => {
|
||||||
return data?.pages.flatMap(page => page.data) || [];
|
return data?.pages.flatMap(page => page.data) || [];
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
// Refetch when manual update state changes
|
const listData = useMemo<ListRow[]>(() => {
|
||||||
|
const groups: Record<string, Props[]> = {}
|
||||||
|
const dateOrder: string[] = []
|
||||||
|
|
||||||
|
flatData.forEach((item) => {
|
||||||
|
if (!groups[item.createdAt]) {
|
||||||
|
groups[item.createdAt] = []
|
||||||
|
dateOrder.push(item.createdAt)
|
||||||
|
}
|
||||||
|
groups[item.createdAt].push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: ListRow[] = []
|
||||||
|
dateOrder.forEach((date) => {
|
||||||
|
result.push({ _type: 'header', date })
|
||||||
|
const sorted = [...groups[date]].sort((a, b) => Number(a.isRead) - Number(b.isRead))
|
||||||
|
sorted.forEach((item) => result.push({ ...item, _type: 'item' }))
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}, [flatData])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetch()
|
refetch()
|
||||||
}, [updateNotification, refetch])
|
}, [updateNotification, refetch])
|
||||||
@@ -71,16 +106,10 @@ export default function Notification() {
|
|||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMoreData = () => {
|
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function handleReadNotification(id: string, category: string, idContent: string) {
|
async function handleReadNotification(id: string, category: string, idContent: string) {
|
||||||
try {
|
try {
|
||||||
const hasil = await decryptToken(String(token?.current))
|
const hasil = await decryptToken(String(token?.current))
|
||||||
const response = await apiReadOneNotification({ user: hasil, id: id })
|
await apiReadOneNotification({ user: hasil, id: id })
|
||||||
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||||
pushToPage(category, idContent)
|
pushToPage(category, idContent)
|
||||||
dispatch(setUpdateNotification(!updateNotification))
|
dispatch(setUpdateNotification(!updateNotification))
|
||||||
@@ -89,58 +118,35 @@ export default function Notification() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrSkeleton = [0, 1, 2, 3, 4]
|
|
||||||
|
|
||||||
const getItem = (_data: unknown, index: number): Props => ({
|
|
||||||
id: flatData[index]?.id,
|
|
||||||
title: flatData[index]?.title,
|
|
||||||
desc: flatData[index]?.desc,
|
|
||||||
category: flatData[index]?.category,
|
|
||||||
idContent: flatData[index]?.idContent,
|
|
||||||
isRead: flatData[index]?.isRead,
|
|
||||||
createdAt: flatData[index]?.createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
|
<SafeAreaView style={[Styles.flex1, { backgroundColor: colors.background }]}>
|
||||||
<View style={[Styles.p15]}>
|
<Stack.Screen
|
||||||
{
|
options={{
|
||||||
isLoading ?
|
header: () => (
|
||||||
arrSkeleton.map((item, index) => {
|
<AppHeader title="Notifikasi" showBack={true} onPressLeft={() => router.back()} />
|
||||||
return (
|
|
||||||
<SkeletonTwoItem key={index} />
|
|
||||||
)
|
)
|
||||||
})
|
|
||||||
:
|
|
||||||
flatData.length > 0 ?
|
|
||||||
<VirtualizedList
|
|
||||||
data={flatData}
|
|
||||||
getItemCount={() => flatData.length}
|
|
||||||
getItem={getItem}
|
|
||||||
renderItem={({ item, index }: { item: Props, index: number }) => {
|
|
||||||
return (
|
|
||||||
<BorderBottomItemVertical
|
|
||||||
borderType="bottom"
|
|
||||||
icon={
|
|
||||||
<View style={[Styles.iconContent, item.isRead && ColorsStatus.secondary]}>
|
|
||||||
<Feather name="bell" size={25} color="black" />
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
title={item.title}
|
|
||||||
rightTopInfo={item.createdAt}
|
|
||||||
desc={item.desc}
|
|
||||||
textColor={item.isRead ? 'gray' : colors.text}
|
|
||||||
onPress={() => {
|
|
||||||
handleReadNotification(item.id, item.category, item.idContent)
|
|
||||||
}}
|
}}
|
||||||
bgColor={'transparent'}
|
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
}}
|
<View style={[Styles.flex1, Styles.ph15, { paddingTop: 10 }]}>
|
||||||
|
{isLoading ? (
|
||||||
|
[0, 1, 2, 3, 4].map((_, i) => <SkeletonTwoItem key={i} />)
|
||||||
|
) : flatData.length === 0 ? (
|
||||||
|
<View style={[Styles.contentItemCenter, Styles.mt30]}>
|
||||||
|
<Feather name="bell-off" size={42} color={colors.icon + '40'} />
|
||||||
|
<Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
|
||||||
|
Tidak ada notifikasi
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={listData}
|
||||||
keyExtractor={(item, index) => String(index)}
|
keyExtractor={(item, index) => String(index)}
|
||||||
onEndReached={loadMoreData}
|
|
||||||
onEndReachedThreshold={0.5}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage && !isFetchingNextPage) fetchNextPage()
|
||||||
|
}}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -148,10 +154,76 @@ export default function Notification() {
|
|||||||
tintColor={colors.icon}
|
tintColor={colors.icon}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
renderItem={({ item }) => {
|
||||||
:
|
if (item._type === 'header') {
|
||||||
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada data</Text>
|
return (
|
||||||
|
<View style={[Styles.rowItemsCenter, { marginTop: 16, marginBottom: 8 }]}>
|
||||||
|
<Text style={{ fontSize: 11, fontWeight: '600', color: colors.dimmed, letterSpacing: 0.6, textTransform: 'uppercase' }}>
|
||||||
|
{item.date}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flex: 1, height: 1, backgroundColor: colors.icon + '20', marginLeft: 8 }} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { icon, color } = getNotifStyle(item.category)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => handleReadNotification(item.id, item.category, item.idContent)}
|
||||||
|
style={({ pressed }) => [{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.icon + '20',
|
||||||
|
backgroundColor: pressed
|
||||||
|
? colors.icon + '10'
|
||||||
|
: item.isRead
|
||||||
|
? colors.icon + '10'
|
||||||
|
: colors.card,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginBottom: 6,
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
{/* Colored icon */}
|
||||||
|
<View style={{
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: 21,
|
||||||
|
backgroundColor: color + '20',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<Feather name={icon} size={20} color={color} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={[Styles.flex1, { marginLeft: 10 }]}>
|
||||||
|
<View style={[Styles.rowSpaceBetween, Styles.itemsCenter]}>
|
||||||
|
<View style={[Styles.flex1, Styles.mr10]}>
|
||||||
|
<Text
|
||||||
|
style={[Styles.textDefaultSemiBold, { color: item.isRead ? colors.dimmed : colors.text }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={[Styles.textMediumNormal, { color: item.isRead ? colors.dimmed : colors.text, opacity: item.isRead ? 0.7 : 1 }]}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{item.desc}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user