chahinebrini 14452b2a46 refactor(native): Pressable → TouchableOpacity sweep (style-fn swallows Android styles)
Alle <Pressable style={({pressed}) => ({...})}> ersetzt — style-Funktion
droppt auf Android (New Arch) intermittierend width/height, führt zu 0×0
unsichtbaren Elementen. TouchableOpacity mit activeOpacity ist stabil.

Außerdem übrige Pressables (plain style) aus components/ und app/
migriert sowie zwei überschüssige </View>-Tags in chat.tsx + RoomCard.tsx
entfernt die TS-Fehler verursacht haben.

64 Dateien, typecheck sauber.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:43:10 +02:00

788 lines
25 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import {
View,
Text,
TouchableOpacity,
Switch,
LayoutAnimation,
Platform,
UIManager,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { MenuView } from '@react-native-menu/menu';
import { getCitiesForBundesland } from '../../lib/germanCities';
import { WheelPickerModal } from '../WheelPickerModal';
import { useColors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan';
// Geburtsjahr-Optionen: 2010 (oldest 13y) → 1920, descending (neueste oben)
const BIRTH_YEAR_OPTIONS = Array.from({ length: 91 }, (_, i) => 2010 - i).map((y) => ({
value: y,
label: String(y),
}));
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
export type Demographics = {
birthYear: number | null;
gender: string | null;
maritalStatus: string | null;
employmentStatus: string | null;
shiftWork: boolean | null;
industry: string | null;
jobTenure: string | null;
bundesland: string | null;
city: string | null;
};
type Props = {
demographics: Demographics;
plan: Plan;
expanded?: boolean;
defaultExpanded?: boolean;
onChange?: (next: Demographics) => void;
onRevokeConsent?: () => void;
};
const GENDER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'männlich', value: 'male' },
{ label: 'weiblich', value: 'female' },
{ label: 'divers', value: 'diverse' },
];
const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'ledig', value: 'single' },
{ label: 'Partnerschaft', value: 'partnered' },
{ label: 'verheiratet', value: 'married' },
{ label: 'geschieden', value: 'divorced' },
{ label: 'verwitwet', value: 'widowed' },
{ label: 'keine Angabe', value: 'no_answer' },
];
const EMPLOYMENT_STATUS_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'angestellt', value: 'employed' },
{ label: 'selbständig', value: 'self_employed' },
{ label: 'in Ausbildung / Studium', value: 'in_training' },
{ label: 'arbeitslos / arbeitssuchend', value: 'unemployed' },
{ label: 'pensioniert / im Ruhestand', value: 'retired' },
{ label: 'Hausarbeit / Care-Arbeit', value: 'homemaking' },
{ label: 'andere', value: 'other' },
];
const INDUSTRY_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'IT / Software', value: 'it_software' },
{ label: 'Pflege / Medizin', value: 'healthcare' },
{ label: 'Bildung / Lehre', value: 'education' },
{ label: 'Gastronomie / Hotellerie', value: 'hospitality' },
{ label: 'Bau / Handwerk', value: 'construction' },
{ label: 'Banking / Finance', value: 'banking_finance' },
{ label: 'Verkauf / Marketing', value: 'sales_marketing' },
{ label: 'Verwaltung / Behörde', value: 'public_admin' },
{ label: 'Logistik / Transport', value: 'logistics' },
{ label: 'Kreativ / Medien', value: 'creative_media' },
{ label: 'andere', value: 'other' },
];
const JOB_TENURE_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'weniger als 1 Jahr', value: 'less_1y' },
{ label: '1-3 Jahre', value: '1_3y' },
{ label: '3-5 Jahre', value: '3_5y' },
{ label: '5-10 Jahre', value: '5_10y' },
{ label: 'mehr als 10 Jahre', value: 'more_10y' },
];
// Backend zod-regex: ^DE-(BW|BY|...)$ — values müssen mit `DE-` prefix gesendet werden
const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'Baden-Württemberg', value: 'DE-BW' },
{ label: 'Bayern', value: 'DE-BY' },
{ label: 'Berlin', value: 'DE-BE' },
{ label: 'Brandenburg', value: 'DE-BB' },
{ label: 'Bremen', value: 'DE-HB' },
{ label: 'Hamburg', value: 'DE-HH' },
{ label: 'Hessen', value: 'DE-HE' },
{ label: 'Mecklenburg-Vorpommern', value: 'DE-MV' },
{ label: 'Niedersachsen', value: 'DE-NI' },
{ label: 'Nordrhein-Westfalen', value: 'DE-NW' },
{ label: 'Rheinland-Pfalz', value: 'DE-RP' },
{ label: 'Saarland', value: 'DE-SL' },
{ label: 'Sachsen', value: 'DE-SN' },
{ label: 'Sachsen-Anhalt', value: 'DE-ST' },
{ label: 'Schleswig-Holstein', value: 'DE-SH' },
{ label: 'Thüringen', value: 'DE-TH' },
];
const STATUS_WITH_SHIFT: Array<string> = ['employed', 'self_employed'];
const STATUS_WITH_INDUSTRY: Array<string> = ['employed', 'self_employed', 'in_training'];
const STATUS_WITH_TENURE: Array<string> = ['employed', 'self_employed'];
function relevantFieldCount(d: Demographics): { filled: number; total: number } {
const base = [d.birthYear !== null, !!d.gender, !!d.maritalStatus, !!d.employmentStatus, !!d.bundesland, !!d.city];
let filled = base.filter(Boolean).length;
let total = base.length;
const status = d.employmentStatus;
if (status && STATUS_WITH_SHIFT.includes(status)) {
total += 1;
if (d.shiftWork !== null) filled += 1;
}
if (status && STATUS_WITH_INDUSTRY.includes(status)) {
total += 1;
if (!!d.industry) filled += 1;
}
if (status && STATUS_WITH_TENURE.includes(status)) {
total += 1;
if (!!d.jobTenure) filled += 1;
}
return { filled, total };
}
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 mockPersist(_next: Demographics) {}
export function DemographicsAccordion({
demographics,
plan,
expanded: expandedProp,
defaultExpanded = false,
onChange,
onRevokeConsent,
}: Props) {
const [expandedLocal, setExpandedLocal] = useState(defaultExpanded);
useEffect(() => {
if (expandedProp) {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpandedLocal(true);
}
}, [expandedProp]);
const expanded = expandedLocal;
const [local, setLocal] = useState<Demographics>(demographics);
// Generic wheel-picker state — alle Demographics-Auswahlfelder rendern via Wheel
// (iOS 26 hat ActionSheetIOS rendering geändert → wheel ist konsistenter UX-Pattern)
type WheelConfig = {
title: string;
options: Array<{ value: string | number; label: string }>;
value: string | number | null;
onSelect: (v: any) => void;
};
const [wheelConfig, setWheelConfig] = useState<WheelConfig | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setLocal(demographics);
}, [demographics]);
function toggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpandedLocal((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 colors = useColors();
const { filled, total } = relevantFieldCount(local);
const completed = filled === total;
const showProTrialBanner = plan === 'free';
const progressRatio = total > 0 ? filled / total : 0;
const showShiftWork = !!local.employmentStatus && STATUS_WITH_SHIFT.includes(local.employmentStatus);
const showIndustry = !!local.employmentStatus && STATUS_WITH_INDUSTRY.includes(local.employmentStatus);
const showJobTenure = !!local.employmentStatus && STATUS_WITH_TENURE.includes(local.employmentStatus);
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<TouchableOpacity
onPress={toggle}
activeOpacity={0.7}
>
<View
style={{
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
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>
{completed ? (
<View style={{ marginTop: 10, flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="heart" size={14} color={colors.brandOrange} />
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
Danke dass du ReBreak vertraust
</Text>
</View>
) : (
<View style={{ marginTop: 10, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<View
style={{
flex: 1,
height: 5,
backgroundColor: colors.border,
borderRadius: 999,
overflow: 'hidden',
}}
>
<View
style={{
height: 5,
width: `${Math.round(progressRatio * 100)}%`,
backgroundColor: colors.brandOrange,
borderRadius: 999,
}}
/>
</View>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
minWidth: 40,
textAlign: 'right',
}}
>
{filled}/{total}
</Text>
</View>
)}
</View>
</TouchableOpacity>
{expanded ? (
<View
style={{
marginTop: 8,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
paddingVertical: 4,
}}
>
{showProTrialBanner ? (
<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',
}}
>
{completed
? '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}
<FieldRow
label="Geburtsjahr"
why="Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe."
filled={local.birthYear !== null}
>
<SelectButton
value={local.birthYear !== null ? String(local.birthYear) : null}
onPress={() =>
setWheelConfig({
title: 'Geburtsjahr',
options: BIRTH_YEAR_OPTIONS,
value: local.birthYear,
onSelect: (v) => flushSave({ ...local, birthYear: v as number }),
})
}
/>
</FieldRow>
<FieldRow
label="Geschlecht"
why="Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel."
filled={!!local.gender}
>
{/* ≤3 Optionen → UIMenu (anchored Pull-Down). Apple HIG-konform. */}
<MenuView
title="Geschlecht"
actions={GENDER_OPTIONS.map((opt) => ({
id: opt.value,
title: opt.label,
state: opt.value === local.gender ? 'on' : 'off',
}))}
onPressAction={({ nativeEvent: { event } }) =>
flushSave({ ...local, gender: event })
}
shouldOpenOnLongPress={false}
>
<TouchableOpacity
hitSlop={8}
activeOpacity={0.6}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<View
style={{
paddingVertical: 6,
paddingHorizontal: 12,
backgroundColor: '#f4f4f5',
borderRadius: 999,
borderWidth: 1,
borderColor: '#e4e4e7',
}}
>
<Text
style={{
fontSize: 13,
color: local.gender ? colors.text : colors.textMuted,
fontFamily: local.gender ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
}}
>
{lookupLabel(GENDER_OPTIONS, local.gender) ?? 'auswählen'}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
</TouchableOpacity>
</MenuView>
</FieldRow>
<FieldRow
label="Familienstand"
why="Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir."
filled={!!local.maritalStatus}
>
<SelectButton
value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)}
onPress={() =>
setWheelConfig({
title: 'Familienstand',
options: MARITAL_OPTIONS,
value: local.maritalStatus,
onSelect: (v) => flushSave({ ...local, maritalStatus: v as string }),
})
}
/>
</FieldRow>
{/* Beruf-Section */}
<View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 4 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.6,
}}
>
BERUF
</Text>
</View>
<FieldRow
label="Status"
why="Schichtarbeit, Banking-Stress und Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext."
filled={!!local.employmentStatus}
indent
>
<SelectButton
value={lookupLabel(EMPLOYMENT_STATUS_OPTIONS, local.employmentStatus)}
onPress={() =>
setWheelConfig({
title: 'Berufs-Status',
options: EMPLOYMENT_STATUS_OPTIONS,
value: local.employmentStatus,
onSelect: (raw) => {
const v = raw as string;
const next = {
...local,
employmentStatus: v,
shiftWork: STATUS_WITH_SHIFT.includes(v) ? local.shiftWork : null,
industry: STATUS_WITH_INDUSTRY.includes(v) ? local.industry : null,
jobTenure: STATUS_WITH_TENURE.includes(v) ? local.jobTenure : null,
};
flushSave(next);
},
})
}
/>
</FieldRow>
{showShiftWork ? (
<FieldRow
label="Schichtarbeit"
why=""
filled={local.shiftWork !== null}
indent
hideWhy
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{local.shiftWork === null ? 'k.A.' : local.shiftWork ? 'Ja' : 'Nein'}
</Text>
<Switch
value={local.shiftWork === true}
onValueChange={(v) => flushSave({ ...local, shiftWork: v })}
trackColor={{ false: colors.border, true: colors.brandOrange }}
thumbColor="#ffffff"
/>
</View>
</FieldRow>
) : null}
{showIndustry ? (
<FieldRow
label="Branche"
why=""
filled={!!local.industry}
indent
hideWhy
>
<SelectButton
value={lookupLabel(INDUSTRY_OPTIONS, local.industry)}
onPress={() =>
setWheelConfig({
title: 'Branche',
options: INDUSTRY_OPTIONS,
value: local.industry,
onSelect: (v) => flushSave({ ...local, industry: v as string }),
})
}
/>
</FieldRow>
) : null}
{showJobTenure ? (
<FieldRow
label="Im Job seit"
why=""
filled={!!local.jobTenure}
indent
hideWhy
>
<SelectButton
value={lookupLabel(JOB_TENURE_OPTIONS, local.jobTenure)}
onPress={() =>
setWheelConfig({
title: 'Im aktuellen Job seit',
options: JOB_TENURE_OPTIONS,
value: local.jobTenure,
onSelect: (v) => flushSave({ ...local, jobTenure: v as string }),
})
}
/>
</FieldRow>
) : null}
{/* Wohnort-Section */}
<View
style={{
paddingHorizontal: 14,
paddingTop: 12,
paddingBottom: 4,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
marginTop: 4,
}}
>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.6,
}}
>
WOHNORT
</Text>
</View>
<FieldRow
label="Bundesland"
why=""
filled={!!local.bundesland}
indent
hideWhy
>
<SelectButton
value={lookupLabel(BUNDESLAND_OPTIONS, local.bundesland)}
onPress={() =>
setWheelConfig({
title: 'Bundesland',
options: BUNDESLAND_OPTIONS,
value: local.bundesland,
onSelect: (raw) => {
const v = raw as string;
flushSave({ ...local, bundesland: v, city: local.bundesland !== v ? null : local.city });
},
})
}
/>
</FieldRow>
{/* Stadt nur sichtbar wenn Bundesland gewählt */}
{local.bundesland ? (
<FieldRow
label="Stadt"
why="Lokale Beratungsstellen + anonyme DiGA-Studien."
filled={!!local.city}
indent
isLast
>
<SelectButton
value={local.city}
onPress={() =>
setWheelConfig({
title: 'Stadt',
options: getCitiesForBundesland(local.bundesland).map((c) => ({ value: c, label: c })),
value: local.city,
onSelect: (v) => flushSave({ ...local, city: v as string }),
})
}
/>
</FieldRow>
) : null}
<TouchableOpacity
onPress={onRevokeConsent}
activeOpacity={0.7}
>
<View
style={{
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>
</View>
</TouchableOpacity>
</View>
) : null}
<WheelPickerModal
visible={wheelConfig !== null}
title={wheelConfig?.title ?? ''}
options={(wheelConfig?.options ?? []) as Array<{ value: string | number; label: string }>}
value={wheelConfig?.value ?? null}
onSelect={(v) => wheelConfig?.onSelect(v)}
onClose={() => setWheelConfig(null)}
/>
</View>
);
}
function FieldRow({
label,
why,
isLast,
indent,
hideWhy,
filled,
children,
}: {
label: string;
why: string;
isLast?: boolean;
indent?: boolean;
hideWhy?: boolean;
filled: boolean;
children: React.ReactNode;
}) {
const colors = useColors();
return (
<View
style={{
paddingHorizontal: indent ? 20 : 14,
paddingVertical: 12,
borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: colors.border,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 }}>
<Ionicons
name={filled ? 'checkmark-circle' : 'warning-outline'}
size={14}
color={filled ? '#16a34a' : '#f59e0b'}
/>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
flex: 1,
}}
>
{label}
</Text>
</View>
{children}
</View>
{!hideWhy && why ? (
<Text
style={{
marginTop: 6,
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
lineHeight: 15,
}}
>
{why}
</Text>
) : null}
</View>
);
}
function SelectButton({ value, onPress }: { value: string | null; onPress: () => void }) {
const colors = useColors();
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.6}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
{/* Value als Chip */}
<View
style={{
paddingVertical: 6,
paddingHorizontal: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 999,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Text
style={{
fontSize: 13,
color: value ? colors.text : colors.textMuted,
fontFamily: value ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
}}
>
{value ?? 'auswählen'}
</Text>
</View>
{/* Chevron-right am Ende, separat vom Chip */}
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
</TouchableOpacity>
);
}