- urge.tsx: TtsProviderToggle + LlmProviderToggle entfernt (Testing durch); Core-Logic (useTtsProvider, currentLlmProvider, BenchSession) bleibt für spätere Debug-Page intakt - DemographicsAccordion FieldRow: flex:1 auf Label-Text, kein flexShrink- Wrapper mehr nötig; Label+Input wrappen nicht mehr auf schmalen Devices Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
623 lines
18 KiB
TypeScript
623 lines
18 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
TextInput,
|
|
Modal,
|
|
LayoutAnimation,
|
|
Platform,
|
|
UIManager,
|
|
ScrollView,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { colors } from '../../lib/theme';
|
|
import type { Plan } from '../../hooks/useUserPlan';
|
|
|
|
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
|
UIManager.setLayoutAnimationEnabledExperimental(true);
|
|
}
|
|
|
|
export type Demographics = {
|
|
birthYear: number | null;
|
|
gender: string | null;
|
|
maritalStatus: string | null;
|
|
profession: string | null;
|
|
bundesland: string | null;
|
|
city: string | null;
|
|
};
|
|
|
|
type Props = {
|
|
demographics: Demographics;
|
|
plan: Plan;
|
|
defaultExpanded?: boolean;
|
|
onChange?: (next: Demographics) => void;
|
|
onRevokeConsent?: () => void;
|
|
};
|
|
|
|
// Select-Optionen — Display-Label DE, value für DB-Persistenz
|
|
const GENDER_OPTIONS: Array<{ label: string; value: string }> = [
|
|
{ label: 'männlich', value: 'male' },
|
|
{ label: 'weiblich', value: 'female' },
|
|
{ label: 'divers', value: 'diverse' },
|
|
{ label: 'keine Angabe', value: 'none' },
|
|
];
|
|
|
|
const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
|
|
{ label: 'ledig', value: 'single' },
|
|
{ label: 'Partnerschaft', value: 'partnership' },
|
|
{ label: 'verheiratet', value: 'married' },
|
|
{ label: 'geschieden', value: 'divorced' },
|
|
{ label: 'verwitwet', value: 'widowed' },
|
|
{ label: 'keine Angabe', value: 'none' },
|
|
];
|
|
|
|
// ISO-3166-2:DE — value=ISO, label=DE-Display
|
|
const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
|
|
{ label: 'Baden-Württemberg', value: 'BW' },
|
|
{ label: 'Bayern', value: 'BY' },
|
|
{ label: 'Berlin', value: 'BE' },
|
|
{ label: 'Brandenburg', value: 'BB' },
|
|
{ label: 'Bremen', value: 'HB' },
|
|
{ label: 'Hamburg', value: 'HH' },
|
|
{ label: 'Hessen', value: 'HE' },
|
|
{ label: 'Mecklenburg-Vorpommern', value: 'MV' },
|
|
{ label: 'Niedersachsen', value: 'NI' },
|
|
{ label: 'Nordrhein-Westfalen', value: 'NW' },
|
|
{ label: 'Rheinland-Pfalz', value: 'RP' },
|
|
{ label: 'Saarland', value: 'SL' },
|
|
{ label: 'Sachsen', value: 'SN' },
|
|
{ label: 'Sachsen-Anhalt', value: 'ST' },
|
|
{ label: 'Schleswig-Holstein', value: 'SH' },
|
|
{ label: 'Thüringen', value: 'TH' },
|
|
];
|
|
|
|
const FIELD_WHY: Record<keyof Demographics, string> = {
|
|
birthYear:
|
|
'Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe.',
|
|
gender: 'Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel.',
|
|
profession:
|
|
'Schichtarbeit, Banking-Stress, Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext.',
|
|
maritalStatus:
|
|
'Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir.',
|
|
bundesland: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
|
|
city: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
|
|
};
|
|
|
|
function lookupLabel(options: Array<{ label: string; value: string }>, v: string | null) {
|
|
if (!v) return null;
|
|
return options.find((o) => o.value === v)?.label ?? v;
|
|
}
|
|
|
|
function isComplete(d: Demographics) {
|
|
return (
|
|
d.birthYear !== null &&
|
|
!!d.gender &&
|
|
!!d.maritalStatus &&
|
|
!!d.profession &&
|
|
!!d.bundesland &&
|
|
!!d.city
|
|
);
|
|
}
|
|
|
|
// TODO Phase C: PATCH /api/profile/me/demographics — debounced auto-save (~500ms idle).
|
|
// Bis Endpoint live: lokaler State + onChange-Callback Richtung Parent.
|
|
function mockPersist(_next: Demographics) {
|
|
// no-op placeholder — Parent ruft echten Endpoint
|
|
}
|
|
|
|
export function DemographicsAccordion({
|
|
demographics,
|
|
plan,
|
|
defaultExpanded = false,
|
|
onChange,
|
|
onRevokeConsent,
|
|
}: Props) {
|
|
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
const [local, setLocal] = useState<Demographics>(demographics);
|
|
|
|
// Select-Sheet-State
|
|
const [pickerField, setPickerField] = useState<keyof Demographics | null>(null);
|
|
|
|
// Debounce-Save Ref
|
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
setLocal(demographics);
|
|
}, [demographics]);
|
|
|
|
function toggle() {
|
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
|
setExpanded((v) => !v);
|
|
}
|
|
|
|
function persist(next: Demographics) {
|
|
setLocal(next);
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
saveTimer.current = setTimeout(() => {
|
|
mockPersist(next);
|
|
onChange?.(next);
|
|
}, 500);
|
|
}
|
|
|
|
function flushSave(next: Demographics) {
|
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
|
mockPersist(next);
|
|
onChange?.(next);
|
|
setLocal(next);
|
|
}
|
|
|
|
const completed = isComplete(local);
|
|
const showProTrialBanner = plan === 'free' && completed;
|
|
|
|
return (
|
|
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
|
|
{/* Privacy-Header */}
|
|
<Pressable
|
|
onPress={toggle}
|
|
style={({ pressed }) => ({
|
|
opacity: pressed ? 0.7 : 1,
|
|
backgroundColor: '#ffffff',
|
|
borderWidth: 1,
|
|
borderColor: '#e5e5e5',
|
|
borderRadius: 14,
|
|
padding: 16,
|
|
})}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_700Bold',
|
|
letterSpacing: 0.8,
|
|
}}
|
|
>
|
|
ANONYMER BEITRAG ZUR FORSCHUNG
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
marginTop: 6,
|
|
fontSize: 13,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
}}
|
|
>
|
|
Optional. Niemals mit Name oder Email verknüpft. Jederzeit löschbar.
|
|
</Text>
|
|
</View>
|
|
<Ionicons
|
|
name={expanded ? 'chevron-up' : 'chevron-down'}
|
|
size={18}
|
|
color={colors.textMuted}
|
|
/>
|
|
</View>
|
|
</Pressable>
|
|
|
|
{expanded ? (
|
|
<View
|
|
style={{
|
|
marginTop: 8,
|
|
backgroundColor: '#ffffff',
|
|
borderWidth: 1,
|
|
borderColor: '#e5e5e5',
|
|
borderRadius: 14,
|
|
paddingVertical: 4,
|
|
}}
|
|
>
|
|
{/* Pro-Trial-Reward-Banner — nur free + (idealerweise) nicht-vollständig.
|
|
Wir zeigen ihn aber auch im "completed"-State als sanfte Bestätigung,
|
|
tatsächliche Trial-Vergabe ist Backend-Sache (Phase C). */}
|
|
{plan === 'free' ? (
|
|
<View
|
|
style={{
|
|
marginHorizontal: 8,
|
|
marginVertical: 8,
|
|
backgroundColor: '#fff7ed',
|
|
borderColor: '#fed7aa',
|
|
borderWidth: 1,
|
|
borderRadius: 12,
|
|
padding: 12,
|
|
flexDirection: 'row',
|
|
gap: 10,
|
|
alignItems: 'flex-start',
|
|
}}
|
|
>
|
|
<Ionicons name="gift-outline" size={18} color="#c2410c" style={{ marginTop: 1 }} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
color: '#7c2d12',
|
|
fontFamily: 'Nunito_700Bold',
|
|
}}
|
|
>
|
|
{showProTrialBanner
|
|
? 'Du bekommst 1 Woche Pro geschenkt'
|
|
: 'Vervollständige dein Profil — 1 Woche Pro geschenkt'}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
marginTop: 4,
|
|
fontSize: 11,
|
|
color: '#9a3412',
|
|
fontFamily: 'Nunito_400Regular',
|
|
lineHeight: 15,
|
|
}}
|
|
>
|
|
Mit deinen anonymen Daten machen wir rebreak zur ersten DiGA-zertifizierten
|
|
Spielsucht-App. Als Dankeschön: 1 Woche Pro.
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Birth Year — Number-Input */}
|
|
<FieldRow
|
|
label="Geburtsjahr"
|
|
why={FIELD_WHY.birthYear}
|
|
>
|
|
<TextInput
|
|
value={local.birthYear !== null ? String(local.birthYear) : ''}
|
|
onChangeText={(raw) => {
|
|
const cleaned = raw.replace(/[^0-9]/g, '').slice(0, 4);
|
|
if (cleaned === '') {
|
|
persist({ ...local, birthYear: null });
|
|
return;
|
|
}
|
|
const n = parseInt(cleaned, 10);
|
|
// Erlaube tippen — Validierung beim Blur
|
|
persist({ ...local, birthYear: Number.isNaN(n) ? null : n });
|
|
}}
|
|
onBlur={() => {
|
|
const n = local.birthYear;
|
|
if (n !== null && (n < 1920 || n > 2010)) {
|
|
// ungültig — auf null zurücksetzen
|
|
flushSave({ ...local, birthYear: null });
|
|
}
|
|
}}
|
|
keyboardType="number-pad"
|
|
maxLength={4}
|
|
placeholder="z.B. 1989"
|
|
placeholderTextColor={colors.textMuted}
|
|
style={inputStyle}
|
|
/>
|
|
</FieldRow>
|
|
|
|
{/* Gender — Select */}
|
|
<FieldRow label="Geschlecht" why={FIELD_WHY.gender}>
|
|
<SelectButton
|
|
value={lookupLabel(GENDER_OPTIONS, local.gender)}
|
|
onPress={() => setPickerField('gender')}
|
|
/>
|
|
</FieldRow>
|
|
|
|
{/* Profession — TextInput */}
|
|
<FieldRow label="Beruf" why={FIELD_WHY.profession}>
|
|
<TextInput
|
|
value={local.profession ?? ''}
|
|
onChangeText={(t) => persist({ ...local, profession: t })}
|
|
onBlur={() => {
|
|
const trimmed = (local.profession ?? '').trim();
|
|
flushSave({ ...local, profession: trimmed === '' ? null : trimmed });
|
|
}}
|
|
maxLength={80}
|
|
placeholder="z.B. Pflege, IT, Schichtarbeit"
|
|
placeholderTextColor={colors.textMuted}
|
|
style={inputStyle}
|
|
/>
|
|
</FieldRow>
|
|
|
|
{/* Marital — Select */}
|
|
<FieldRow label="Familienstand" why={FIELD_WHY.maritalStatus}>
|
|
<SelectButton
|
|
value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)}
|
|
onPress={() => setPickerField('maritalStatus')}
|
|
/>
|
|
</FieldRow>
|
|
|
|
{/* Bundesland — Select */}
|
|
<FieldRow label="Bundesland" why={FIELD_WHY.bundesland}>
|
|
<SelectButton
|
|
value={lookupLabel(BUNDESLAND_OPTIONS, local.bundesland)}
|
|
onPress={() => setPickerField('bundesland')}
|
|
/>
|
|
</FieldRow>
|
|
|
|
{/* City — TextInput */}
|
|
<FieldRow label="Stadt" why={FIELD_WHY.city} isLast>
|
|
<TextInput
|
|
value={local.city ?? ''}
|
|
onChangeText={(t) => persist({ ...local, city: t })}
|
|
onBlur={() => {
|
|
const trimmed = (local.city ?? '').trim();
|
|
flushSave({ ...local, city: trimmed === '' ? null : trimmed });
|
|
}}
|
|
maxLength={60}
|
|
placeholder="z.B. München"
|
|
placeholderTextColor={colors.textMuted}
|
|
style={inputStyle}
|
|
/>
|
|
</FieldRow>
|
|
|
|
{/* Revoke Consent */}
|
|
<Pressable
|
|
onPress={onRevokeConsent}
|
|
style={({ pressed }) => ({
|
|
opacity: pressed ? 0.7 : 1,
|
|
marginTop: 4,
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
borderTopWidth: 1,
|
|
borderTopColor: 'rgba(0,0,0,0.06)',
|
|
})}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: colors.error,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
Einwilligung widerrufen
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
|
|
<SelectSheet
|
|
visible={pickerField === 'gender'}
|
|
title="Geschlecht"
|
|
options={GENDER_OPTIONS}
|
|
selectedValue={local.gender}
|
|
onClose={() => setPickerField(null)}
|
|
onSelect={(v) => {
|
|
flushSave({ ...local, gender: v });
|
|
setPickerField(null);
|
|
}}
|
|
/>
|
|
<SelectSheet
|
|
visible={pickerField === 'maritalStatus'}
|
|
title="Familienstand"
|
|
options={MARITAL_OPTIONS}
|
|
selectedValue={local.maritalStatus}
|
|
onClose={() => setPickerField(null)}
|
|
onSelect={(v) => {
|
|
flushSave({ ...local, maritalStatus: v });
|
|
setPickerField(null);
|
|
}}
|
|
/>
|
|
<SelectSheet
|
|
visible={pickerField === 'bundesland'}
|
|
title="Bundesland"
|
|
options={BUNDESLAND_OPTIONS}
|
|
selectedValue={local.bundesland}
|
|
onClose={() => setPickerField(null)}
|
|
onSelect={(v) => {
|
|
flushSave({ ...local, bundesland: v });
|
|
setPickerField(null);
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const inputStyle = {
|
|
fontSize: 14,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 10,
|
|
backgroundColor: '#fafafa',
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: '#ececec',
|
|
minWidth: 140,
|
|
textAlign: 'right' as const,
|
|
};
|
|
|
|
function FieldRow({
|
|
label,
|
|
why,
|
|
isLast,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
why: string;
|
|
isLast?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 12,
|
|
borderBottomWidth: isLast ? 0 : 1,
|
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
flex: 1,
|
|
fontSize: 13,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
}}
|
|
>
|
|
{label}
|
|
</Text>
|
|
{children}
|
|
</View>
|
|
<Text
|
|
style={{
|
|
marginTop: 6,
|
|
fontSize: 11,
|
|
color: colors.textMuted,
|
|
fontFamily: 'Nunito_400Regular',
|
|
lineHeight: 15,
|
|
}}
|
|
>
|
|
{why}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SelectButton({ value, onPress }: { value: string | null; onPress: () => void }) {
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
style={({ pressed }) => ({
|
|
opacity: pressed ? 0.6 : 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 10,
|
|
backgroundColor: '#fafafa',
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: '#ececec',
|
|
minWidth: 140,
|
|
justifyContent: 'flex-end',
|
|
})}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: value ? colors.text : colors.textMuted,
|
|
fontFamily: value ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
|
|
textAlign: 'right',
|
|
}}
|
|
>
|
|
{value ?? 'auswählen'}
|
|
</Text>
|
|
<Ionicons name="chevron-down" size={14} color={colors.textMuted} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function SelectSheet({
|
|
visible,
|
|
title,
|
|
options,
|
|
selectedValue,
|
|
onClose,
|
|
onSelect,
|
|
}: {
|
|
visible: boolean;
|
|
title: string;
|
|
options: Array<{ label: string; value: string }>;
|
|
selectedValue: string | null;
|
|
onClose: () => void;
|
|
onSelect: (v: string) => void;
|
|
}) {
|
|
const sortedOptions = useMemo(() => options, [options]);
|
|
|
|
return (
|
|
<Modal
|
|
visible={visible}
|
|
transparent
|
|
animationType="slide"
|
|
onRequestClose={onClose}
|
|
>
|
|
<Pressable
|
|
onPress={onClose}
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
|
justifyContent: 'flex-end',
|
|
}}
|
|
>
|
|
<Pressable
|
|
onPress={() => {
|
|
/* swallow */
|
|
}}
|
|
style={{
|
|
backgroundColor: '#ffffff',
|
|
borderTopLeftRadius: 18,
|
|
borderTopRightRadius: 18,
|
|
paddingHorizontal: 8,
|
|
paddingTop: 12,
|
|
paddingBottom: 24,
|
|
maxHeight: '70%',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 12,
|
|
paddingBottom: 8,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 15,
|
|
color: colors.text,
|
|
fontFamily: 'Nunito_700Bold',
|
|
}}
|
|
>
|
|
{title}
|
|
</Text>
|
|
<Pressable onPress={onClose} hitSlop={10}>
|
|
<Ionicons name="close" size={22} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
<ScrollView style={{ maxHeight: 380 }}>
|
|
{sortedOptions.map((opt) => {
|
|
const isSelected = opt.value === selectedValue;
|
|
return (
|
|
<Pressable
|
|
key={opt.value}
|
|
onPress={() => onSelect(opt.value)}
|
|
style={({ pressed }) => ({
|
|
opacity: pressed ? 0.6 : 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 14,
|
|
borderRadius: 10,
|
|
backgroundColor: isSelected ? '#f5f8ff' : 'transparent',
|
|
})}
|
|
>
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
color: colors.text,
|
|
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
|
}}
|
|
>
|
|
{opt.label}
|
|
</Text>
|
|
{isSelected ? (
|
|
<Ionicons name="checkmark" size={18} color={colors.brandOrange} />
|
|
) : null}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</Pressable>
|
|
</Pressable>
|
|
</Modal>
|
|
);
|
|
}
|