This commit is contained in:
Timo Knuth
2026-02-27 15:19:24 +01:00
parent b7f8221095
commit 253c3c1c6d
134 changed files with 11188 additions and 1871 deletions

View File

@@ -1,4 +1,5 @@
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Platform, Image } from 'react-native'
import React, { useEffect, useRef } from 'react'
import { View, Text, ScrollView, TouchableOpacity, TextInput, StyleSheet, Platform, Image, Animated } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useRouter } from 'expo-router'
@@ -7,8 +8,8 @@ import { de } from 'date-fns/locale'
import { NEWS_KATEGORIE_LABELS } from '@innungsapp/shared/types'
import { useNewsList } from '@/hooks/useNews'
import { useTermineListe } from '@/hooks/useTermine'
import { useNewsReadStore } from '@/store/news.store'
import { trpc } from '@/lib/trpc'
import { getOrgSlug } from '@/lib/org-config'
// Helper to truncate text
function getNewsExcerpt(value: string) {
@@ -19,17 +20,45 @@ function getNewsExcerpt(value: string) {
return normalized.length > 85 ? `${normalized.slice(0, 85)}...` : normalized
}
const AVATAR_PALETTES = [
{ bg: '#003B7E' }, { bg: '#1D4ED8' }, { bg: '#059669' },
{ bg: '#4338CA' }, { bg: '#B45309' }, { bg: '#0F766E' },
]
function Skeleton({ style }: { style?: any }) {
const anim = useRef(new Animated.Value(0.4)).current
useEffect(() => {
Animated.loop(
Animated.sequence([
Animated.timing(anim, { toValue: 1, duration: 1000, useNativeDriver: true }),
Animated.timing(anim, { toValue: 0.4, duration: 1000, useNativeDriver: true }),
])
).start()
}, [anim])
return <Animated.View style={[{ backgroundColor: '#E2E8F0', borderRadius: 8 }, style, { opacity: anim }]} />
}
function getInitials(name: string) {
return name.split(' ').slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('')
}
function getAvatarBg(name: string) {
return AVATAR_PALETTES[name.charCodeAt(0) % AVATAR_PALETTES.length].bg
}
export default function HomeScreen() {
const router = useRouter()
const { data: newsItems = [] } = useNewsList()
const { data: termine = [] } = useTermineListe(true)
const readIds = useNewsReadStore((s) => s.readIds)
const { data: newsItems = [], isLoading: newsLoading } = useNewsList()
const { data: termine = [], isLoading: termineLoading } = useTermineListe(true)
const { data: me } = trpc.members.me.useQuery()
const { data: unread } = trpc.messages.unreadCount.useQuery(undefined, { refetchInterval: 15_000 })
const { data: org } = trpc.organizations.bySlug.useQuery({ slug: getOrgSlug() })
const userName = me?.name ?? ''
const orgName = org?.name ?? 'InnungsApp'
const latestNews = newsItems.slice(0, 2)
const upcomingEvents = termine.slice(0, 3)
const unreadCount = newsItems.filter((item) => !(item.isRead || readIds.has(item.id))).length
const unreadCount = unread?.count ?? 0
const QUICK_ACTIONS = [
{ label: 'Mitglieder', icon: 'people-circle', color: '#2563EB', bg: '#DBEAFE', route: '/(app)/members' },
@@ -51,11 +80,16 @@ export default function HomeScreen() {
{/* Header Section */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>I</Text>
</View>
<TouchableOpacity
onPress={() => router.push('/(app)/profil' as never)}
activeOpacity={0.85}
>
<View style={[styles.avatar, { backgroundColor: userName ? getAvatarBg(userName) : '#003B7E' }]}>
<Text style={styles.avatarText}>{userName ? getInitials(userName) : '?'}</Text>
</View>
</TouchableOpacity>
<View>
<Text style={styles.greeting}>Willkommen zurück,</Text>
<Text style={styles.greeting}>{orgName}</Text>
<Text style={styles.username}>{userName}</Text>
</View>
</View>
@@ -63,8 +97,9 @@ export default function HomeScreen() {
<TouchableOpacity
style={styles.notificationBtn}
activeOpacity={0.8}
onPress={() => router.push('/(app)/chat' as never)}
>
<Ionicons name="notifications-outline" size={22} color="#1E293B" />
<Ionicons name="chatbubbles-outline" size={22} color="#1E293B" />
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount}</Text>
@@ -114,28 +149,37 @@ export default function HomeScreen() {
</View>
<View style={styles.cardsColumn}>
{latestNews.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.newsCard}
activeOpacity={0.9}
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
>
<View style={styles.newsHeader}>
<View style={styles.categoryBadge}>
<Text style={styles.categoryText}>
{NEWS_KATEGORIE_LABELS[item.kategorie]}
{newsLoading ? (
<>
<Skeleton style={[styles.newsCard, { height: 130 }]} />
<Skeleton style={[styles.newsCard, { height: 130 }]} />
</>
) : latestNews.length > 0 ? (
latestNews.map((item) => (
<TouchableOpacity
key={item.id}
style={styles.newsCard}
activeOpacity={0.9}
onPress={() => router.push(`/(app)/news/${item.id}` as never)}
>
<View style={styles.newsHeader}>
<View style={styles.categoryBadge}>
<Text style={styles.categoryText}>
{NEWS_KATEGORIE_LABELS[item.kategorie]}
</Text>
</View>
<Text style={styles.dateText}>
{item.publishedAt ? format(item.publishedAt, 'dd. MMM', { locale: de }) : 'Entwurf'}
</Text>
</View>
<Text style={styles.dateText}>
{item.publishedAt ? format(item.publishedAt, 'dd. MMM', { locale: de }) : 'Entwurf'}
</Text>
</View>
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.newsBody} numberOfLines={2}>{getNewsExcerpt(item.body)}</Text>
</TouchableOpacity>
))}
<Text style={styles.newsTitle} numberOfLines={2}>{item.title}</Text>
<Text style={styles.newsBody} numberOfLines={2}>{getNewsExcerpt(item.body)}</Text>
</TouchableOpacity>
))
) : (
<Text style={styles.emptyText}>Keine aktuellen Neuigkeiten</Text>
)}
</View>
</View>
@@ -149,28 +193,58 @@ export default function HomeScreen() {
</View>
<View style={styles.eventsList}>
{upcomingEvents.map((event, index) => (
<TouchableOpacity
key={event.id}
style={[styles.eventRow, index !== upcomingEvents.length - 1 && styles.eventBorder]}
onPress={() => router.push(`/(app)/termine/${event.id}` as never)}
activeOpacity={0.7}
>
<View style={styles.dateBox}>
<Text style={styles.dateMonth}>{format(event.datum, 'MMM', { locale: de })}</Text>
<Text style={styles.dateDay}>{format(event.datum, 'dd')}</Text>
{termineLoading ? (
<>
<View style={[styles.eventRow, styles.eventBorder]}>
<Skeleton style={styles.dateBox} />
<View style={styles.eventInfo}>
<Skeleton style={{ width: '70%', height: 16, marginBottom: 6 }} />
<Skeleton style={{ width: '40%', height: 12 }} />
</View>
</View>
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>{event.titel}</Text>
<Text style={styles.eventMeta} numberOfLines={1}>
{event.uhrzeit} {event.ort}
</Text>
<View style={[styles.eventRow, styles.eventBorder]}>
<Skeleton style={styles.dateBox} />
<View style={styles.eventInfo}>
<Skeleton style={{ width: '60%', height: 16, marginBottom: 6 }} />
<Skeleton style={{ width: '50%', height: 12 }} />
</View>
</View>
<View style={styles.eventRow}>
<Skeleton style={styles.dateBox} />
<View style={styles.eventInfo}>
<Skeleton style={{ width: '80%', height: 16, marginBottom: 6 }} />
<Skeleton style={{ width: '30%', height: 12 }} />
</View>
</View>
</>
) : upcomingEvents.length > 0 ? (
upcomingEvents.map((event, index) => (
<TouchableOpacity
key={event.id}
style={[styles.eventRow, index !== upcomingEvents.length - 1 && styles.eventBorder]}
onPress={() => router.push(`/(app)/termine/${event.id}` as never)}
activeOpacity={0.7}
>
<View style={styles.dateBox}>
<Text style={styles.dateMonth}>{format(event.datum, 'MMM', { locale: de })}</Text>
<Text style={styles.dateDay}>{format(event.datum, 'dd')}</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#CBD5E1" />
</TouchableOpacity>
))}
<View style={styles.eventInfo}>
<Text style={styles.eventTitle} numberOfLines={1}>{event.titel}</Text>
<Text style={styles.eventMeta} numberOfLines={1}>
{event.uhrzeit} {event.ort}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#CBD5E1" />
</TouchableOpacity>
))
) : (
<View style={{ padding: 16 }}>
<Text style={styles.emptyText}>Keine anstehenden Termine</Text>
</View>
)}
</View>
</View>
@@ -221,20 +295,21 @@ const styles = StyleSheet.create({
avatar: {
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: '#003B7E',
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#003B7E',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 6,
elevation: 3,
},
avatarText: {
color: '#FFFFFF',
fontSize: 20,
fontWeight: '700',
fontSize: 16,
fontWeight: '800',
lineHeight: 20,
includeFontPadding: false,
},
greeting: {
fontSize: 13,
@@ -463,4 +538,10 @@ const styles = StyleSheet.create({
color: '#64748B',
fontWeight: '500',
},
emptyText: {
fontSize: 14,
color: '#94A3B8',
textAlign: 'center',
paddingVertical: 12,
},
})