ki integration
This commit is contained in:
208
client/src/app/(tabs)/profil.tsx
Normal file
208
client/src/app/(tabs)/profil.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { Button } from '@/components/ui';
|
||||
import { TYPEN, assetsMitBild, type AssetMitBild, type AssetTyp } from '@/lib/assets';
|
||||
import { useBildQuelle } from '@/lib/bildquelle';
|
||||
import { reifegrad, type Ordner } from '@/lib/folders';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space, text } from '@/theme/tokens';
|
||||
|
||||
export default function ProfilScreen() {
|
||||
const router = useRouter();
|
||||
const { brand, ordner, aktiverOrdner, setzeAktivenOrdner, ordnerNeuLaden, ausloggen } = useSession();
|
||||
const [modelle, setModelle] = useState<AssetMitBild[]>([]);
|
||||
const [laedt, setLaedt] = useState(false);
|
||||
|
||||
const laden = useCallback(async () => {
|
||||
if (!brand) return;
|
||||
setLaedt(true);
|
||||
try {
|
||||
const [m] = await Promise.all([assetsMitBild(brand.$id), ordnerNeuLaden()]);
|
||||
setModelle(m);
|
||||
} finally {
|
||||
setLaedt(false);
|
||||
}
|
||||
}, [brand, ordnerNeuLaden]);
|
||||
|
||||
// Nach dem Anlegen kommt man per router.back() zurück – ohne das hier stünde
|
||||
// die Liste noch auf dem alten Stand.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void laden();
|
||||
}, [laden]),
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={s.body}
|
||||
refreshControl={<RefreshControl refreshing={laedt} onRefresh={laden} tintColor={colors.mut} />}>
|
||||
<Text style={s.marke}>{brand?.label_name ?? 'Profil'}</Text>
|
||||
|
||||
{/* Ordner stehen oben, nicht unter den Metriken – sie sind der Zugang
|
||||
zum Wissens-Scope (app-aufbau.md §4.5). */}
|
||||
<Abschnitt
|
||||
titel="Ordner"
|
||||
aktion="Neu"
|
||||
aufAktion={() => router.push('/ordner/neu')}
|
||||
leer={ordner.length === 0 ? 'Noch kein Ordner. Der erste bestimmt, woraus generiert wird.' : undefined}>
|
||||
{ordner.map((o) => (
|
||||
<OrdnerZeile
|
||||
key={o.$id}
|
||||
ordner={o}
|
||||
aktiv={o.$id === aktiverOrdner?.$id}
|
||||
aufWahl={() => setzeAktivenOrdner(o)}
|
||||
aufOeffnen={() => router.push(`/ordner/${o.$id}`)}
|
||||
/>
|
||||
))}
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt
|
||||
titel="Modelle"
|
||||
aktion="Neu"
|
||||
aufAktion={() => router.push('/modell/neu')}
|
||||
leer={modelle.length === 0 ? 'Noch kein Modell. Personen, Produkte und Kulissen kommen hierher.' : undefined}>
|
||||
{TYPEN.map((t) => {
|
||||
const davon = modelle.filter((m) => m.typ === t.wert);
|
||||
if (!davon.length) return null;
|
||||
return (
|
||||
<View key={t.wert} style={s.gruppe}>
|
||||
<Text style={s.gruppeTitel}>{t.titel}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.reihe}>
|
||||
{davon.map((m) => (
|
||||
<ModellKachel key={m.$id} modell={m} />
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</Abschnitt>
|
||||
|
||||
<View style={s.fuss}>
|
||||
<Text style={s.fussText}>{brand?.plan ?? 'trial'} · {brand?.$id}</Text>
|
||||
<Button title="Abmelden" variant="ghost" onPress={() => void ausloggen()} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Abschnitt({
|
||||
titel, aktion, aufAktion, leer, children,
|
||||
}: {
|
||||
titel: string; aktion: string; aufAktion: () => void; leer?: string; children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View style={s.abschnitt}>
|
||||
<View style={s.abschnittKopf}>
|
||||
<Text style={s.abschnittTitel}>{titel}</Text>
|
||||
<Pressable onPress={aufAktion} accessibilityRole="button" style={s.aktion}>
|
||||
<Ionicons name="add" size={16} color={colors.accent} />
|
||||
<Text style={s.aktionText}>{aktion}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{leer ? <Text style={s.leer}>{leer}</Text> : children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdnerZeile({
|
||||
ordner, aktiv, aufWahl, aufOeffnen,
|
||||
}: {
|
||||
ordner: Ordner; aktiv: boolean; aufWahl: () => void; aufOeffnen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={[s.zeile, aktiv ? s.zeileAktiv : null]}>
|
||||
{/* Antippen wählt, der Pfeil öffnet – zwei verschiedene Absichten, die
|
||||
man sonst nicht auseinanderhalten kann. */}
|
||||
<Pressable
|
||||
onPress={aufWahl}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected: aktiv }}
|
||||
accessibilityLabel={`${ordner.name} als aktiven Ordner wählen`}
|
||||
style={({ pressed }) => [s.zeileText, pressed ? s.gedrueckt : null]}>
|
||||
<Text style={s.zeileTitel}>{ordner.name}</Text>
|
||||
<Text style={s.zeileUnter}>
|
||||
{ordner.zweck === 'sammlung' ? 'Sammlung' : 'Wissens-Scope'} · {reifegrad(ordner)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{aktiv ? (
|
||||
<View style={s.aktivChip}>
|
||||
<Text style={s.aktivChipText}>aktiv</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Pressable onPress={aufOeffnen} accessibilityRole="button" accessibilityLabel={`${ordner.name} öffnen`} hitSlop={8}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.faint} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ModellKachel({ modell }: { modell: AssetMitBild }) {
|
||||
const quelle = useBildQuelle(modell.titelbildId);
|
||||
return (
|
||||
<View style={s.kachel}>
|
||||
{quelle ? (
|
||||
<Image source={quelle} style={s.kachelBild} contentFit="cover" transition={150} />
|
||||
) : (
|
||||
<View style={[s.kachelBild, s.kachelLeer]}>
|
||||
<Ionicons name={iconFuer(modell.typ)} size={22} color={colors.faint} />
|
||||
</View>
|
||||
)}
|
||||
<Text style={s.kachelName} numberOfLines={1}>
|
||||
{modell.name}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function iconFuer(typ: AssetTyp): keyof typeof Ionicons.glyphMap {
|
||||
return (TYPEN.find((t) => t.wert === typ)?.icon ?? 'cube-outline') as keyof typeof Ionicons.glyphMap;
|
||||
}
|
||||
|
||||
const s = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
body: { padding: space.xl, gap: space.xl, paddingBottom: space.xxl },
|
||||
marke: { ...text.title, color: colors.txt },
|
||||
|
||||
abschnitt: { gap: space.md },
|
||||
abschnittKopf: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
abschnittTitel: { ...text.heading, color: colors.txt },
|
||||
aktion: { flexDirection: 'row', alignItems: 'center', gap: 2, paddingVertical: space.xs, paddingHorizontal: space.sm },
|
||||
aktionText: { ...text.label, color: colors.accent, fontWeight: '600' },
|
||||
leer: { ...text.body, color: colors.faint, lineHeight: 21 },
|
||||
|
||||
zeile: {
|
||||
flexDirection: 'row', alignItems: 'center', gap: space.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: radius.md, padding: space.lg,
|
||||
},
|
||||
zeileAktiv: { borderColor: colors.accent, backgroundColor: 'rgba(255,92,57,0.08)' },
|
||||
gedrueckt: { opacity: 0.8 },
|
||||
zeileText: { flex: 1, gap: 2 },
|
||||
zeileTitel: { ...text.body, fontWeight: '600', color: colors.txt },
|
||||
zeileUnter: { ...text.label, color: colors.mut },
|
||||
aktivChip: { backgroundColor: colors.accent, borderRadius: radius.pill, paddingHorizontal: space.md, paddingVertical: 3 },
|
||||
aktivChipText: { fontSize: 11, fontWeight: '700', color: '#fff' },
|
||||
|
||||
gruppe: { gap: space.sm },
|
||||
gruppeTitel: { ...text.label, color: colors.faint, textTransform: 'uppercase', letterSpacing: 0.5 },
|
||||
reihe: { gap: space.md, paddingRight: space.xl },
|
||||
kachel: { width: 104, gap: space.sm },
|
||||
kachelBild: {
|
||||
width: 104, height: 104, borderRadius: radius.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
kachelLeer: { alignItems: 'center', justifyContent: 'center' },
|
||||
kachelName: { ...text.label, color: colors.mut },
|
||||
|
||||
fuss: { gap: space.md, marginTop: space.md },
|
||||
fussText: { ...text.mono, color: colors.faint },
|
||||
});
|
||||
Reference in New Issue
Block a user