227 lines
6.8 KiB
TypeScript
227 lines
6.8 KiB
TypeScript
import {
|
|
View, Text, FlatList, TouchableOpacity, RefreshControl, ScrollView, StyleSheet, Animated,
|
|
} from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { useRouter } from 'expo-router'
|
|
import { useFocusEffect } from 'expo-router'
|
|
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
|
|
useEffect(() => {
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(anim, { toValue: 1, duration: 800, useNativeDriver: true }),
|
|
Animated.timing(anim, { toValue: 0.4, duration: 800, useNativeDriver: true }),
|
|
])
|
|
).start()
|
|
}, [])
|
|
return (
|
|
<Animated.View style={[skeletonStyles.card, { opacity: anim }]}>
|
|
<View style={skeletonStyles.badge} />
|
|
<View style={skeletonStyles.titleLine} />
|
|
<View style={skeletonStyles.titleLineShort} />
|
|
<View style={skeletonStyles.metaLine} />
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
const skeletonStyles = StyleSheet.create({
|
|
card: { backgroundColor: '#FFFFFF', borderRadius: 12, padding: 16, marginBottom: 10, marginHorizontal: 16 },
|
|
badge: { height: 20, width: 80, borderRadius: 10, backgroundColor: '#E2E8F0', marginBottom: 12 },
|
|
titleLine: { height: 16, borderRadius: 8, backgroundColor: '#E2E8F0', width: '85%', marginBottom: 8 },
|
|
titleLineShort: { height: 16, borderRadius: 8, backgroundColor: '#E2E8F0', width: '55%', marginBottom: 12 },
|
|
metaLine: { height: 12, borderRadius: 6, backgroundColor: '#F1F5F9', width: '40%' },
|
|
})
|
|
|
|
const FILTERS = [
|
|
{ value: undefined, label: 'Alle' },
|
|
{ value: 'Wichtig', label: 'Wichtig' },
|
|
{ value: 'Pruefung', label: 'Pruefung' },
|
|
{ value: 'Foerderung', label: 'Foerderung' },
|
|
{ value: 'Veranstaltung', label: 'Veranstaltung' },
|
|
]
|
|
|
|
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(() => {
|
|
setShowSkeleton(true)
|
|
const t = setTimeout(() => setShowSkeleton(false), 800)
|
|
return () => clearTimeout(t)
|
|
}, [])
|
|
)
|
|
|
|
// 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>
|
|
</View>
|
|
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.filterScroll}
|
|
>
|
|
{FILTERS.map((opt) => {
|
|
const active = kategorie === opt.value
|
|
const count = getUnreadCount(opt.value)
|
|
return (
|
|
<TouchableOpacity
|
|
key={String(opt.value)}
|
|
onPress={() => setKategorie(opt.value)}
|
|
style={[styles.chip, active && styles.chipActive]}
|
|
activeOpacity={0.85}
|
|
>
|
|
<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>
|
|
)
|
|
})}
|
|
</ScrollView>
|
|
</View>
|
|
|
|
<View style={styles.divider} />
|
|
|
|
{showSkeleton ? (
|
|
<View style={{ paddingTop: 16 }}>
|
|
{[1,2,3,4].map((i) => <SkeletonCard key={i} />)}
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={data ?? []}
|
|
keyExtractor={(item) => item.id}
|
|
contentContainerStyle={styles.list}
|
|
initialNumToRender={10}
|
|
maxToRenderPerBatch={10}
|
|
windowSize={5}
|
|
refreshControl={
|
|
<RefreshControl refreshing={isRefetching} onRefresh={refetch} tintColor="#003B7E" progressViewOffset={50} />
|
|
}
|
|
renderItem={({ item }) => (
|
|
<NewsCard
|
|
news={item}
|
|
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
|
|
/>
|
|
)}
|
|
ListEmptyComponent={
|
|
<EmptyState icon="newspaper-outline" title="Keine News" subtitle="Noch keine Beitraege veroeffentlicht." />
|
|
}
|
|
/>
|
|
)}
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safeArea: {
|
|
flex: 1,
|
|
backgroundColor: '#F8FAFC',
|
|
},
|
|
header: {
|
|
backgroundColor: '#FFFFFF',
|
|
paddingHorizontal: 20,
|
|
paddingTop: 14,
|
|
paddingBottom: 0,
|
|
},
|
|
titleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 14,
|
|
},
|
|
screenTitle: {
|
|
fontSize: 28,
|
|
fontWeight: '800',
|
|
color: '#0F172A',
|
|
letterSpacing: -0.5,
|
|
},
|
|
filterScroll: {
|
|
paddingBottom: 14,
|
|
gap: 8,
|
|
paddingRight: 20,
|
|
},
|
|
chip: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 5,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 7,
|
|
borderRadius: 99,
|
|
borderWidth: 1,
|
|
borderColor: '#E2E8F0',
|
|
backgroundColor: '#FFFFFF',
|
|
},
|
|
chipActive: {
|
|
backgroundColor: '#003B7E',
|
|
borderColor: '#003B7E',
|
|
},
|
|
chipLabel: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
color: '#64748B',
|
|
},
|
|
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',
|
|
},
|
|
list: {
|
|
padding: 16,
|
|
gap: 10,
|
|
paddingBottom: 30,
|
|
},
|
|
})
|