stadtwerke/innungsapp/apps/mobile/app/(app)/news/index.tsx

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,
},
})