stadtwerke/innungsapp/apps/mobile/app/(app)/_layout.tsx

305 lines
9.5 KiB
TypeScript

import { Tabs, Redirect } from 'expo-router'
import { Platform, View, Text, StyleSheet, TextInput, TouchableOpacity, ActivityIndicator, Alert, ScrollView } from 'react-native'
import { useEffect, useState } from 'react'
import { Ionicons } from '@expo/vector-icons'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useAuthStore } from '@/store/auth.store'
import { trpc } from '@/lib/trpc'
import { setupPushNotifications } from '@/lib/notifications'
import { authClient } from '@/lib/auth-client'
function UnreadBadge({ count }: { count: number }) {
if (count === 0) return null
return (
<View style={badge.dot}>
<Text style={badge.text}>{count > 9 ? '9+' : count}</Text>
</View>
)
}
const badge = StyleSheet.create({
dot: {
position: 'absolute',
top: -4,
right: -8,
minWidth: 17,
height: 17,
borderRadius: 9,
backgroundColor: '#DC2626',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 4,
borderWidth: 2,
borderColor: '#FFFFFF',
},
text: {
fontSize: 10,
fontWeight: '700',
color: '#FFFFFF',
lineHeight: 13,
},
})
function ChatTabIcon({ color, focused }: { color: string; focused: boolean }) {
const { data: unreadCount } = trpc.messages.getConversations.useQuery(undefined, {
refetchInterval: 15_000,
staleTime: 10_000,
select: (data) => data.filter((c) => c.hasUnread).length,
})
return (
<View>
<Ionicons name={focused ? 'chatbubbles' : 'chatbubbles-outline'} size={23} color={color} />
<UnreadBadge count={unreadCount ?? 0} />
</View>
)
}
function ForcePasswordChangeScreen() {
const { setSession, signOut } = useAuthStore()
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit() {
setError('')
if (!current) { setError('Bitte temporäres Passwort eingeben.'); return }
if (next.length < 8) { setError('Das neue Passwort muss mindestens 8 Zeichen haben.'); return }
if (next !== confirm) { setError('Die Passwörter stimmen nicht überein.'); return }
setLoading(true)
const result = await authClient.changePassword({ currentPassword: current, newPassword: next })
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'Passwort konnte nicht geändert werden.')
return
}
// Refresh session — mustChangePassword is now false
const sessionResult = await authClient.getSession()
if (sessionResult?.data?.user) {
const u = sessionResult.data.user as any
await setSession({
user: {
id: u.id,
email: u.email,
name: u.name,
mustChangePassword: false,
},
})
}
}
return (
<SafeAreaView style={fpc.safe}>
<ScrollView contentContainerStyle={fpc.content} keyboardShouldPersistTaps="handled">
<View style={fpc.card}>
<View style={fpc.iconWrap}>
<Ionicons name="lock-closed-outline" size={32} color="#003B7E" />
</View>
<Text style={fpc.title}>Passwort ändern</Text>
<Text style={fpc.subtitle}>
Ihr Administrator hat ein temporäres Passwort vergeben. Bitte legen Sie jetzt Ihr persönliches Passwort fest.
</Text>
<View style={fpc.field}>
<Text style={fpc.label}>Temporäres Passwort</Text>
<TextInput
style={fpc.input}
value={current}
onChangeText={setCurrent}
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={fpc.field}>
<Text style={fpc.label}>Neues Passwort</Text>
<TextInput
style={fpc.input}
value={next}
onChangeText={setNext}
secureTextEntry
placeholder="Mindestens 8 Zeichen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
<View style={fpc.field}>
<Text style={fpc.label}>Neues Passwort wiederholen</Text>
<TextInput
style={fpc.input}
value={confirm}
onChangeText={setConfirm}
secureTextEntry
placeholder="Neues Passwort wiederholen"
placeholderTextColor="#CBD5E1"
autoCapitalize="none"
/>
</View>
{!!error && (
<View style={fpc.errorBox}>
<Text style={fpc.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity style={fpc.btn} onPress={handleSubmit} disabled={loading}>
{loading
? <ActivityIndicator color="#fff" />
: <Text style={fpc.btnText}>Passwort festlegen</Text>
}
</TouchableOpacity>
<TouchableOpacity style={fpc.logoutBtn} onPress={() => void signOut()}>
<Text style={fpc.logoutText}>Abmelden</Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
)
}
const fpc = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#F8FAFC' },
content: { flex: 1, justifyContent: 'center', padding: 24 },
card: {
backgroundColor: '#FFFFFF', borderRadius: 20,
borderWidth: 1, borderColor: '#E2E8F0',
padding: 24, gap: 12,
},
iconWrap: {
width: 60, height: 60, borderRadius: 16,
backgroundColor: '#EFF6FF', alignItems: 'center', justifyContent: 'center',
alignSelf: 'center', marginBottom: 4,
},
title: { fontSize: 22, fontWeight: '800', color: '#0F172A', textAlign: 'center' },
subtitle: { fontSize: 13, color: '#64748B', textAlign: 'center', lineHeight: 19 },
field: { gap: 4 },
label: { fontSize: 12, fontWeight: '700', color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5 },
input: {
borderWidth: 1, borderColor: '#E2E8F0', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 11,
fontSize: 14, color: '#0F172A', backgroundColor: '#F8FAFC',
},
errorBox: {
backgroundColor: '#FEF2F2', borderWidth: 1,
borderColor: '#FECACA', borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10,
},
errorText: { color: '#B91C1C', fontSize: 13 },
btn: {
backgroundColor: '#003B7E', borderRadius: 12,
paddingVertical: 13, alignItems: 'center', marginTop: 4,
},
btnText: { color: '#FFFFFF', fontWeight: '700', fontSize: 15 },
logoutBtn: { alignItems: 'center', paddingVertical: 8 },
logoutText: { color: '#94A3B8', fontSize: 13 },
})
export default function AppLayout() {
const session = useAuthStore((s) => s.session)
useEffect(() => {
if (!session?.user) return
setupPushNotifications().catch(() => {})
}, [session?.user?.id])
if (!session) {
return <Redirect href="/(auth)/login" />
}
if (session.user.mustChangePassword) {
return <ForcePasswordChangeScreen />
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#003B7E',
tabBarInactiveTintColor: '#64748B',
tabBarStyle: {
borderTopWidth: 1,
borderTopColor: '#E2E8F0',
backgroundColor: '#FFFFFF',
height: Platform.OS === 'ios' ? 88 : 64,
paddingBottom: Platform.OS === 'ios' ? 28 : 8,
paddingTop: 8,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.1,
},
headerShown: false,
}}
>
<Tabs.Screen
name="home/index"
options={{
title: 'Start',
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'home' : 'home-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen
name="news/index"
options={{
title: 'Aktuelles',
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'newspaper' : 'newspaper-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen
name="termine/index"
options={{
title: 'Termine',
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'calendar' : 'calendar-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen
name="stellen/index"
options={{
title: 'Stellen',
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'briefcase' : 'briefcase-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen
name="chat/index"
options={{
title: 'Nachrichten',
tabBarIcon: ({ color, focused }) => (
<ChatTabIcon color={color} focused={focused} />
),
}}
/>
<Tabs.Screen
name="profil/index"
options={{
title: 'Profil',
tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? 'person' : 'person-outline'} size={23} color={color} />
),
}}
/>
<Tabs.Screen name="members/index" options={{ href: null }} />
<Tabs.Screen name="news/[id]" options={{ href: null }} />
<Tabs.Screen name="members/[id]" options={{ href: null }} />
<Tabs.Screen name="termine/[id]" options={{ href: null }} />
<Tabs.Screen name="stellen/[id]" options={{ href: null }} />
<Tabs.Screen name="chat/[id]" options={{ href: null }} />
</Tabs>
)
}