ki integration

This commit is contained in:
2026-08-15 13:43:52 +02:00
parent 82d98bd8cf
commit 9ecb292c26
78 changed files with 12055 additions and 55 deletions

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