feat: Implement news section with list and detail views, category filtering, and unread indicators.
This commit is contained in:
parent
4863d032d9
commit
244da5e69a
|
|
@ -7,6 +7,7 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
|
|||
import { useEffect } from 'react'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
import { useNewsDetail } from '@/hooks/useNews'
|
||||
import { useNewsReadStore } from '@/store/news.store'
|
||||
import { AttachmentRow } from '@/components/news/AttachmentRow'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
||||
|
|
@ -98,8 +99,14 @@ export default function NewsDetailScreen() {
|
|||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { data: news, isLoading, onOpen } = useNewsDetail(id)
|
||||
const markRead = useNewsReadStore((s) => s.markRead)
|
||||
|
||||
useEffect(() => { if (news) onOpen() }, [news?.id])
|
||||
useEffect(() => {
|
||||
if (news) {
|
||||
markRead(news.id) // sofort lokal markieren → Badge weg beim Zurückgehen
|
||||
onOpen()
|
||||
}
|
||||
}, [news?.id])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -121,7 +128,7 @@ export default function NewsDetailScreen() {
|
|||
{/* Nav bar */}
|
||||
<View style={styles.navBar}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
onPress={() => router.navigate('/(app)/news')}
|
||||
style={styles.backBtn}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useNewsList } from '@/hooks/useNews'
|
|||
import { NewsCard } from '@/components/news/NewsCard'
|
||||
import { EmptyState } from '@/components/ui/EmptyState'
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||
import { useNewsReadStore } from '@/store/news.store'
|
||||
|
||||
function SkeletonCard() {
|
||||
const anim = useRef(new Animated.Value(0.4)).current
|
||||
|
|
@ -50,7 +51,10 @@ export default function NewsScreen() {
|
|||
const router = useRouter()
|
||||
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
|
||||
const [showSkeleton, setShowSkeleton] = useState(true)
|
||||
// Fetch all news (without category filter) to compute per-category unread counts
|
||||
const { data: allData, refetch: refetchAll, isRefetching: isRefetchingAll } = useNewsList(undefined)
|
||||
const { data, isLoading, refetch, isRefetching } = useNewsList(kategorie)
|
||||
const localReadIds = useNewsReadStore((s) => s.readIds)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
|
@ -60,18 +64,20 @@ export default function NewsScreen() {
|
|||
}, [])
|
||||
)
|
||||
|
||||
const unreadCount = data?.filter((n) => !n.isRead).length ?? 0
|
||||
// Compute unread count per category (combining server isRead + local store)
|
||||
function getUnreadCount(filterValue: string | undefined) {
|
||||
const source = allData ?? []
|
||||
const filtered = filterValue === undefined
|
||||
? source
|
||||
: source.filter((n) => n.kategorie === filterValue)
|
||||
return filtered.filter((n) => !n.isRead && !localReadIds.has(n.id)).length
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.screenTitle}>Aktuelles</Text>
|
||||
{unreadCount > 0 && (
|
||||
<View style={styles.unreadBadge}>
|
||||
<Text style={styles.unreadBadgeText}>{unreadCount} neu</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
|
|
@ -81,6 +87,7 @@ export default function NewsScreen() {
|
|||
>
|
||||
{FILTERS.map((opt) => {
|
||||
const active = kategorie === opt.value
|
||||
const count = getUnreadCount(opt.value)
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={String(opt.value)}
|
||||
|
|
@ -91,6 +98,13 @@ export default function NewsScreen() {
|
|||
<Text style={[styles.chipLabel, active && styles.chipLabelActive]}>
|
||||
{opt.label}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<View style={[styles.chipBadge, active && styles.chipBadgeActive]}>
|
||||
<Text style={[styles.chipBadgeText, active && styles.chipBadgeTextActive]}>
|
||||
{count}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})}
|
||||
|
|
@ -152,23 +166,15 @@ const styles = StyleSheet.create({
|
|||
color: '#0F172A',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
unreadBadge: {
|
||||
backgroundColor: '#EFF6FF',
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 99,
|
||||
},
|
||||
unreadBadgeText: {
|
||||
color: '#003B7E',
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
filterScroll: {
|
||||
paddingBottom: 14,
|
||||
gap: 8,
|
||||
paddingRight: 20,
|
||||
},
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 99,
|
||||
|
|
@ -188,6 +194,26 @@ const styles = StyleSheet.create({
|
|||
chipLabelActive: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
chipBadge: {
|
||||
backgroundColor: '#003B7E',
|
||||
borderRadius: 99,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
paddingHorizontal: 5,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
chipBadgeActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
chipBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
chipBadgeTextActive: {
|
||||
color: '#003B7E',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E2E8F0',
|
||||
|
|
|
|||
|
|
@ -24,22 +24,22 @@ export function NewsCard({ news, onPress }: NewsCardProps) {
|
|||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.84}>
|
||||
{!isRead && (
|
||||
<View style={styles.newBadge}>
|
||||
<Text style={styles.newBadgeText}>Neu</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.topRow}>
|
||||
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
|
||||
<View style={styles.badgeRow}>
|
||||
<Badge label={NEWS_KATEGORIE_LABELS[news.kategorie]} kategorie={news.kategorie} />
|
||||
{!isRead && (
|
||||
<View style={styles.newBadge}>
|
||||
<Text style={styles.newBadgeText}>Neu</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.dateText}>
|
||||
{news.publishedAt
|
||||
? format(new Date(news.publishedAt), 'dd. MMM', { locale: de })
|
||||
: 'Entwurf'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={isRead ? styles.titleRead : styles.titleUnread} numberOfLines={2}>
|
||||
{news.title}
|
||||
</Text>
|
||||
|
|
@ -71,15 +71,16 @@ const styles = StyleSheet.create({
|
|||
elevation: 2,
|
||||
position: 'relative',
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
newBadge: {
|
||||
position: 'absolute',
|
||||
right: 30,
|
||||
top: 8,
|
||||
borderRadius: 999,
|
||||
backgroundColor: '#EF4444',
|
||||
paddingHorizontal: 8,
|
||||
paddingHorizontal: 7,
|
||||
paddingVertical: 2,
|
||||
zIndex: 2,
|
||||
},
|
||||
newBadgeText: {
|
||||
color: '#FFFFFF',
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue