RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop). Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
788 lines
25 KiB
TypeScript
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.card,
|
|
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.card,
|
|
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,
|
|
}}
|
|
>
|
|
Deine anonymen Angaben helfen uns, ReBreak gezielt zu verbessern.
|
|
Als Dankeschön: 1 Woche Pro.
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<FieldRow
|
|
label="Geburtsjahr"
|
|
why="Lyra spricht dich altersgerecht an und erkennt altersbedingte Risikofaktoren."
|
|
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="Hilft uns, regionale Unterschiede besser zu verstehen."
|
|
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>
|
|
);
|
|
}
|