232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
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}
|
||
aufDruck={() => router.push({ pathname: '/modell/[id]', params: { id: m.$id } })}
|
||
/>
|
||
))}
|
||
</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, aufDruck }: { modell: AssetMitBild; aufDruck: () => void }) {
|
||
const quelle = useBildQuelle(modell.titelbildId, modell.titelbildBucket);
|
||
return (
|
||
<Pressable
|
||
onPress={aufDruck}
|
||
accessibilityRole="button"
|
||
accessibilityLabel={`${modell.name} öffnen`}
|
||
style={({ pressed }) => [s.kachel, pressed ? s.gedrueckt : null]}>
|
||
<View>
|
||
{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>
|
||
)}
|
||
{/* Ohne freigegebene Version ist das Modell nicht einsetzbar – das muss
|
||
man sehen, bevor man es in einem Post auswählen will. */}
|
||
{modell.wartet ? (
|
||
<View style={s.wartetChip}>
|
||
<Text style={s.wartetText}>wählen</Text>
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
<Text style={s.kachelName} numberOfLines={1}>
|
||
{modell.name}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
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 },
|
||
wartetChip: {
|
||
position: 'absolute', bottom: 5, left: 5,
|
||
backgroundColor: colors.gold, borderRadius: radius.pill,
|
||
paddingHorizontal: space.sm, paddingVertical: 1,
|
||
},
|
||
wartetText: { fontSize: 10, fontWeight: '700', color: '#000' },
|
||
|
||
fuss: { gap: space.md, marginTop: space.md },
|
||
fussText: { ...text.mono, color: colors.faint },
|
||
});
|