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 { useEffect } from 'react'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
import { useNewsDetail } from '@/hooks/useNews'
|
import { useNewsDetail } from '@/hooks/useNews'
|
||||||
|
import { useNewsReadStore } from '@/store/news.store'
|
||||||
import { AttachmentRow } from '@/components/news/AttachmentRow'
|
import { AttachmentRow } from '@/components/news/AttachmentRow'
|
||||||
import { Badge } from '@/components/ui/Badge'
|
import { Badge } from '@/components/ui/Badge'
|
||||||
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
|
||||||
|
|
@ -98,8 +99,14 @@ export default function NewsDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>()
|
const { id } = useLocalSearchParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { data: news, isLoading, onOpen } = useNewsDetail(id)
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -121,7 +128,7 @@ export default function NewsDetailScreen() {
|
||||||
{/* Nav bar */}
|
{/* Nav bar */}
|
||||||
<View style={styles.navBar}>
|
<View style={styles.navBar}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => router.back()}
|
onPress={() => router.navigate('/(app)/news')}
|
||||||
style={styles.backBtn}
|
style={styles.backBtn}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useNewsList } from '@/hooks/useNews'
|
||||||
import { NewsCard } from '@/components/news/NewsCard'
|
import { NewsCard } from '@/components/news/NewsCard'
|
||||||
import { EmptyState } from '@/components/ui/EmptyState'
|
import { EmptyState } from '@/components/ui/EmptyState'
|
||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'
|
||||||
|
import { useNewsReadStore } from '@/store/news.store'
|
||||||
|
|
||||||
function SkeletonCard() {
|
function SkeletonCard() {
|
||||||
const anim = useRef(new Animated.Value(0.4)).current
|
const anim = useRef(new Animated.Value(0.4)).current
|
||||||
|
|
@ -50,7 +51,10 @@ export default function NewsScreen() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
|
const [kategorie, setKategorie] = useState<string | undefined>(undefined)
|
||||||
const [showSkeleton, setShowSkeleton] = useState(true)
|
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 { data, isLoading, refetch, isRefetching } = useNewsList(kategorie)
|
||||||
|
const localReadIds = useNewsReadStore((s) => s.readIds)
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<Text style={styles.screenTitle}>Aktuelles</Text>
|
<Text style={styles.screenTitle}>Aktuelles</Text>
|
||||||
{unreadCount > 0 && (
|
|
||||||
<View style={styles.unreadBadge}>
|
|
||||||
<Text style={styles.unreadBadgeText}>{unreadCount} neu</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
|
@ -81,6 +87,7 @@ export default function NewsScreen() {
|
||||||
>
|
>
|
||||||
{FILTERS.map((opt) => {
|
{FILTERS.map((opt) => {
|
||||||
const active = kategorie === opt.value
|
const active = kategorie === opt.value
|
||||||
|
const count = getUnreadCount(opt.value)
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={String(opt.value)}
|
key={String(opt.value)}
|
||||||
|
|
@ -91,6 +98,13 @@ export default function NewsScreen() {
|
||||||
<Text style={[styles.chipLabel, active && styles.chipLabelActive]}>
|
<Text style={[styles.chipLabel, active && styles.chipLabelActive]}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</Text>
|
</Text>
|
||||||
|
{count > 0 && (
|
||||||
|
<View style={[styles.chipBadge, active && styles.chipBadgeActive]}>
|
||||||
|
<Text style={[styles.chipBadgeText, active && styles.chipBadgeTextActive]}>
|
||||||
|
{count}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -152,23 +166,15 @@ const styles = StyleSheet.create({
|
||||||
color: '#0F172A',
|
color: '#0F172A',
|
||||||
letterSpacing: -0.5,
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
unreadBadge: {
|
|
||||||
backgroundColor: '#EFF6FF',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 4,
|
|
||||||
borderRadius: 99,
|
|
||||||
},
|
|
||||||
unreadBadgeText: {
|
|
||||||
color: '#003B7E',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
filterScroll: {
|
filterScroll: {
|
||||||
paddingBottom: 14,
|
paddingBottom: 14,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
paddingRight: 20,
|
paddingRight: 20,
|
||||||
},
|
},
|
||||||
chip: {
|
chip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
paddingVertical: 7,
|
paddingVertical: 7,
|
||||||
borderRadius: 99,
|
borderRadius: 99,
|
||||||
|
|
@ -188,6 +194,26 @@ const styles = StyleSheet.create({
|
||||||
chipLabelActive: {
|
chipLabelActive: {
|
||||||
color: '#FFFFFF',
|
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: {
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: '#E2E8F0',
|
backgroundColor: '#E2E8F0',
|
||||||
|
|
|
||||||
|
|
@ -24,22 +24,22 @@ export function NewsCard({ news, onPress }: NewsCardProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={onPress} style={styles.card} activeOpacity={0.84}>
|
<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.content}>
|
||||||
<View style={styles.topRow}>
|
<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}>
|
<Text style={styles.dateText}>
|
||||||
{news.publishedAt
|
{news.publishedAt
|
||||||
? format(new Date(news.publishedAt), 'dd. MMM', { locale: de })
|
? format(new Date(news.publishedAt), 'dd. MMM', { locale: de })
|
||||||
: 'Entwurf'}
|
: 'Entwurf'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={isRead ? styles.titleRead : styles.titleUnread} numberOfLines={2}>
|
<Text style={isRead ? styles.titleRead : styles.titleUnread} numberOfLines={2}>
|
||||||
{news.title}
|
{news.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -71,15 +71,16 @@ const styles = StyleSheet.create({
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
|
badgeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
newBadge: {
|
newBadge: {
|
||||||
position: 'absolute',
|
|
||||||
right: 30,
|
|
||||||
top: 8,
|
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: '#EF4444',
|
backgroundColor: '#EF4444',
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 7,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
zIndex: 2,
|
|
||||||
},
|
},
|
||||||
newBadgeText: {
|
newBadgeText: {
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue