feat: redesign halaman notifikasi dengan icon berwarna, grouping tanggal, dan urutan unread-first

This commit is contained in:
2026-05-08 14:18:53 +08:00
parent bc2c89e030
commit 4eebf2f893

View File

@@ -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,70 +118,113 @@ 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} /> }}
) />
})
: <View style={[Styles.flex1, Styles.ph15, { paddingTop: 10 }]}>
flatData.length > 0 ? {isLoading ? (
<VirtualizedList [0, 1, 2, 3, 4].map((_, i) => <SkeletonTwoItem key={i} />)
data={flatData} ) : flatData.length === 0 ? (
getItemCount={() => flatData.length} <View style={[Styles.contentItemCenter, Styles.mt30]}>
getItem={getItem} <Feather name="bell-off" size={42} color={colors.icon + '40'} />
renderItem={({ item, index }: { item: Props, index: number }) => { <Text style={[Styles.mt10, { color: colors.dimmed, fontSize: 14 }]}>
return ( Tidak ada notifikasi
<BorderBottomItemVertical </Text>
borderType="bottom" </View>
icon={ ) : (
<View style={[Styles.iconContent, item.isRead && ColorsStatus.secondary]}> <FlatList
<Feather name="bell" size={25} color="black" /> data={listData}
</View> keyExtractor={(item, index) => String(index)}
} showsVerticalScrollIndicator={false}
title={item.title} onEndReached={() => {
rightTopInfo={item.createdAt} if (hasNextPage && !isFetchingNextPage) fetchNextPage()
desc={item.desc} }}
textColor={item.isRead ? 'gray' : colors.text} onEndReachedThreshold={0.5}
onPress={() => { refreshControl={
handleReadNotification(item.id, item.category, item.idContent) <RefreshControl
}} refreshing={refreshing}
bgColor={'transparent'} onRefresh={handleRefresh}
/> tintColor={colors.icon}
)
}}
keyExtractor={(item, index) => String(index)}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.icon}
/>
}
/> />
: }
<Text style={[Styles.textDefault, Styles.textCenter, { color: colors.dimmed }]}>Tidak ada data</Text> renderItem={({ item }) => {
} if (item._type === 'header') {
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>
) )
} }