From 244da5e69af2e31cfe226329ce53b0d7d834edff Mon Sep 17 00:00:00 2001 From: Timo Knuth Date: Fri, 27 Feb 2026 19:36:20 +0100 Subject: [PATCH] feat: Implement news section with list and detail views, category filtering, and unread indicators. --- .../apps/mobile/app/(app)/news/[id].tsx | 11 +++- .../apps/mobile/app/(app)/news/index.tsx | 60 +++++++++++++----- .../apps/mobile/components/news/NewsCard.tsx | 27 ++++---- innungsapp/packages/shared/prisma/dev.db | Bin 258048 -> 258048 bytes 4 files changed, 66 insertions(+), 32 deletions(-) diff --git a/innungsapp/apps/mobile/app/(app)/news/[id].tsx b/innungsapp/apps/mobile/app/(app)/news/[id].tsx index 819042c..95fb99a 100644 --- a/innungsapp/apps/mobile/app/(app)/news/[id].tsx +++ b/innungsapp/apps/mobile/app/(app)/news/[id].tsx @@ -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 */} router.back()} + onPress={() => router.navigate('/(app)/news')} style={styles.backBtn} activeOpacity={0.7} > diff --git a/innungsapp/apps/mobile/app/(app)/news/index.tsx b/innungsapp/apps/mobile/app/(app)/news/index.tsx index f0ae26c..f2268f8 100644 --- a/innungsapp/apps/mobile/app/(app)/news/index.tsx +++ b/innungsapp/apps/mobile/app/(app)/news/index.tsx @@ -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(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 ( Aktuelles - {unreadCount > 0 && ( - - {unreadCount} neu - - )} {FILTERS.map((opt) => { const active = kategorie === opt.value + const count = getUnreadCount(opt.value) return ( {opt.label} + {count > 0 && ( + + + {count} + + + )} ) })} @@ -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', diff --git a/innungsapp/apps/mobile/components/news/NewsCard.tsx b/innungsapp/apps/mobile/components/news/NewsCard.tsx index 8fe847f..d6b7b23 100644 --- a/innungsapp/apps/mobile/components/news/NewsCard.tsx +++ b/innungsapp/apps/mobile/components/news/NewsCard.tsx @@ -24,22 +24,22 @@ export function NewsCard({ news, onPress }: NewsCardProps) { return ( - {!isRead && ( - - Neu - - )} - - + + + {!isRead && ( + + Neu + + )} + {news.publishedAt ? format(new Date(news.publishedAt), 'dd. MMM', { locale: de }) : 'Entwurf'} - {news.title} @@ -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', diff --git a/innungsapp/packages/shared/prisma/dev.db b/innungsapp/packages/shared/prisma/dev.db index 3e66ff83a2135609385ee303795e3b8b90bedee4..62c4ae489deb32b949cac6f394e99198bdb559a2 100644 GIT binary patch delta 1068 zcma)5Pe>F|9NwAPbzNuYJ+*^DE$l(Tu$S}Z&(03vpVijT*mT|2bVYM^{)m>Vxn?Ah zc1a=%D`*frNObBFv4^G3c8Ch>5G3R(OAtm~>!dY1Y6TtK%Yz@k@B4ng_r34UZNj-t zxNwA6zkjrqSbskBq^bNom2$YZ;3u#~uexa}m8on_Q0DRt(pe*hN0srj`o*4T+q67+ z=}c=2+a2qRUOUxuS)G#GdK1-DgfQ3gls}Vg?fB+%I2<(H?|_St18;!FY%}%Vm)Z!4Fq+J z0-S0fLBt$VRNj&@I7QSs&fu`|kl6VA>O*R!3S9t z2L*fa!{`j~_dRxf zcOOrbniU)53G+^l1kRJrI5rE^s{h~D`=>d-lOwY`IjV%9An7yRo0N|veVI`CPh#-q s@q{Phinv^LWB8!YNi3~gSh;$TC52oy&+VQj{!BGJ;48Cl^OV5+1|A48Q~&?~ delta 457 zcmZp8z~AtIe}XjQ^@%dhjMq0NEV1VcW1h^w{*wO=-%aid?A)709HN*f2MDN7Ur@)y zGX3TwW`)h$oxR?O@=jx5Z&5|sW42?{Tl1xlf6H_hCb*O1rT?<8)(xri)zQU@%~R z$>+LVAb=?jDGdGt!(jXNU?#5`QNEc#YhxMsV)+I6uJZfxALZ}l?*v-w&Np4~K9k<` z?t4spjzGI@7|HV^hc!#6A>;Iewv3wX%=ekLGv8-sWMt-JIy0U90kiC8M}ZYg+s`~= HHsJ*T68?r>