feat: Implement news section with list and detail views, category filtering, and unread indicators.

This commit is contained in:
Timo Knuth 2026-02-27 19:36:20 +01:00
parent 4863d032d9
commit 244da5e69a
4 changed files with 66 additions and 32 deletions

View File

@ -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}
>

View File

@ -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',

View File

@ -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',