feat(native): WIP checkpoint — Profile/Settings/Demographics + WheelPicker + Maestro

Rollback-Punkt vor Expo SDK 54 / RN 0.81 Upgrade.

UI/UX:
- Profile: ProfileHeader redesign (sign-in chip + member-since), StatsBar 3 pill cards,
  Demographics accordion completed (Geburtsjahr, Geschlecht, Familienstand, Beruf-split,
  Wohnort), Pro-Trial-Banner, Approved-Domains list, DigaMissionBanner
- Settings: section-based layout, neutral icons (matched Header dropdown style)
- Header dropdown: extended with logout + games-page link
- Notifications page: skeleton dummy data
- Locales: i18n keys for new screens

New components:
- WheelPickerModal: native iOS UIPickerView wheel for long lists (Geburtsjahr 91 items,
  Bundesland 16, Stadt 30+/Bundesland)
- OptionsBottomSheet: iOS-style options sheet (used briefly for Geschlecht, currently
  unused — kept for potential future use)
- germanCities.ts: Top-cities per Bundesland (DSGVO-clean static data)

New libs (NewArch-codegen verified):
- @react-native-menu/menu 2.0.0 (UIMenu wrapper, Apple HIG-konform)
- @lodev09/react-native-true-sheet 3.10.1 (UISheetPresentationController wrapper —
  ABER incompatible mit RN 0.79.6, Build-Error → Trigger für SDK-54-Upgrade)

Maestro E2E:
- Initial setup mit auth/community/profile/urge flows

Scripts:
- build-ios-clean.sh: Xcode DerivedData + ios/build cleanup vor expo run:ios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-08 19:32:27 +02:00
parent d7efd627f5
commit 3c52d8869e
43 changed files with 8596 additions and 1021 deletions

View File

@ -0,0 +1,217 @@
# Maestro E2E — Local Setup (Phase A)
Phase A: lokales CLI, 0 Cost, kein Cloud-Account.
Phase B (post-TestFlight): Maestro Cloud fuer CI-Reports + Multi-Device-Parallel — Entscheidung steht aus.
---
## 1. Maestro CLI installieren
```bash
curl -Ls "https://get.maestro.mobile.dev" | bash
```
Danach Shell neu starten oder:
```bash
export PATH="$PATH:$HOME/.maestro/bin"
```
Verify:
```bash
maestro --version
# Erwarteter Output: Maestro CLI 1.x.x
```
---
## 2. App auf Device / Simulator bauen
Maestro benoetigt eine laufende App-Installation. Expo Dev-Client oder Production-Build.
### iOS Simulator
```bash
# Im rebreak-native Verzeichnis:
cd apps/rebreak-native
# Dev-Build auf Standard-Simulator (erster verfuegbarer):
pnpm exec expo run:ios
# Spezifischer Simulator (Name aus "xcrun simctl list devices"):
pnpm exec expo run:ios --device "iPhone 15"
```
Alternativ (wenn dev-iphone.sh vorhanden):
```bash
bash apps/rebreak-native/dev-iphone.sh
```
### Android Emulator
Emulator muss vorher gestartet sein (`Android Studio -> Device Manager`).
```bash
bash apps/rebreak-native/install-android.sh
```
Oder direkt:
```bash
pnpm exec expo run:android
```
### Bundle-ID
`org.rebreak.app` (iOS + Android identisch) — steht so in `app.config.ts` und in den
Flow-Headern als `appId: org.rebreak.app`.
---
## 3. Env-Vars setzen
Flows benoetigen Test-User-Credentials. **Nie** hardcoden — immer als Env-Vars uebergeben.
```bash
export E2E_TEST_USER=claude-android-test
export E2E_TEST_PASSWORD=<Passwort aus Infisical>
```
Oder via Infisical:
```bash
infisical run -- maestro test apps/rebreak-native/.maestro/auth/signin.yaml
```
Variablen die Flows erwarten:
| Var | Beschreibung |
|----------------------|-----------------------------------------------|
| `E2E_TEST_USER` | Username ohne @rebreak.internal |
| `E2E_TEST_PASSWORD` | Passwort des Test-Users auf Staging |
Wichtig: Der Backend-Server haengt `@rebreak.internal` automatisch an den Username.
In den Flows steht deshalb `${E2E_TEST_USER}@rebreak.internal` als E-Mail-Input.
---
## 4. Flows ausfuehren
### Einen einzelnen Flow
```bash
maestro test apps/rebreak-native/.maestro/auth/signin.yaml \
--env=E2E_TEST_USER=claude-android-test \
--env=E2E_TEST_PASSWORD=<passwort>
```
### Alle Flows (sequenziell)
```bash
maestro test apps/rebreak-native/.maestro/
```
Mit ENV-Datei-Uebergabe:
```bash
maestro test \
--env=E2E_TEST_USER=claude-android-test \
--env=E2E_TEST_PASSWORD=<passwort> \
apps/rebreak-native/.maestro/
```
### Spezifische Subdirectory
```bash
maestro test apps/rebreak-native/.maestro/auth/
```
---
## 5. Flow-Entwicklung: Maestro Studio
Visual Flow-Builder im Browser — zeigt Screen-Snapshot + verfuegbare Elemente.
```bash
# App muss laufen auf Device/Simulator:
maestro studio
```
Browser oeffnet auf `http://localhost:9999`. Elemente anklicken, YAML auto-generieren,
dann in `.maestro/<area>/<scenario>.yaml` speichern.
---
## 6. Tipps fuer stabile Flows
**Warte auf Animationen:**
```yaml
- waitForAnimationToEnd:
timeout: 4000
```
Splash-Screen, Screen-Transitions und API-Calls brauchen Zeit.
Faustregel: nach `launchApp` min. 5000ms, nach Navigation 2000-4000ms.
**Flaky Tests debuggen:**
```bash
# Output-Logs waehrend Run:
maestro test --format junit --output report.xml apps/rebreak-native/.maestro/auth/signin.yaml
# Screenshot bei Fehler: Maestro macht automatisch einen Screenshot im Fehlerfall.
# Findet sich unter ~/.maestro/tests/<timestamp>/
```
**Selektoren — Prioritaet:**
1. `id: "<testID>"` — stabielste Option. RN-Prop: `testID="mein-btn"`.
2. `text: "..."` — nur fuer statische, locale-unabhaengige Strings.
3. `point: "x%, y%"` — letzter Ausweg, bricht bei Screen-Size-Aenderungen.
4. Niemals `text:` fuer i18n-Strings (`t('...')`-Output) wenn Locale-Wechsel moeglich.
**Device angeben (Multi-Device):**
```bash
maestro test --device=<DEVICE_ID> apps/rebreak-native/.maestro/
# Device-IDs: `adb devices` (Android) / `xcrun simctl list` (iOS)
```
---
## 7. App-State vor Test-Lauf
`clearState: true` in jedem Flow-Header stellt sicher dass:
- Auth-Session geleert ist
- Kein persistierter State (MMKV / AsyncStorage) den Flow stoert
- Jeder Flow von einem definierten Ausgangspunkt startet (Login-Screen)
Test-User muss **vorab** auf dem Staging-Backend existieren:
- Username: `claude-android-test`
- E-Mail: `claude-android-test@rebreak.internal`
- Account: email-confirmed, kein Admin-Flag
- Erstellung: nur per Service-Role-Key + `auth.admin.createUser({ email_confirm: true })`
(nicht im Flow selbst — Test-User ist persistent)
---
## 8. Flow-Uebersicht
| Flow | Was wird geprueft |
|-----------------------------------|----------------------------------------------------------|
| `auth/signin.yaml` | App startet, Login funktioniert, Home-Feed sichtbar |
| `urge/start-session.yaml` | SOS-Button im Dropdown erreichbar, Lyra-Screen laedt |
| `community/post.yaml` | ComposeCard oeffnet, Text-Input funktioniert, Post sendet|
| `profile/view-profile.yaml` | Profil-Navigation via Dropdown, ProfileScreen laedt |
---
## 9. Phase B — Maestro Cloud (Zukunft, post-TestFlight)
Was bei Cloud-Wechsel geaendert werden muss:
1. Maestro-Cloud-Account anlegen: `maestro cloud login`
2. CI-Run-Befehl aendern: `maestro cloud apps/rebreak-native/.maestro/`
3. ENV-Vars in CI-Secret-Store hinterlegen (GitHub Actions Secrets / Infisical CI-Integration)
4. Multi-Device-Matrix: `--device ios` / `--device android` separat schedulen
5. Report-URL aus Cloud-Output in PR-Kommentare posten (GitHub Actions Step)
Bis dahin: lokaler CLI reicht fuer Pre-Release-Smoke-Tests.

View File

@ -0,0 +1,49 @@
# auth/signin.yaml
# Smoke-test: App startet, Sign-in-Screen ist erreichbar, Login mit Test-User funktioniert.
# Selektor-Strategie: text: fuer statische Platzhalter (de.json-Werte, die sich
# nicht via Locale-Wechsel aendern sollten im Test-Setup). Falls i18n-Locale
# in CI auf 'en' steht, MUSS dieser Flow mit E2E_LOCALE=de laufen.
#
# Pre-requisite: App installiert, kein aktiver Auth-State (clearState loesche den).
# Env-Vars: E2E_TEST_USER (username ohne @rebreak.internal), E2E_TEST_PASSWORD
appId: org.rebreak.app
---
- launchApp:
clearState: true
# Warten bis die Auth-Screens geladen sind (Splash kann kurz blockieren)
- waitForAnimationToEnd:
timeout: 5000
# Sign-in-Screen sollte direkt erscheinen (kein Auth-State nach clearState).
# Fallback: falls Onboarding/Landing dazwischenkommt, muessen wir erst weiterklicken.
# Aktuell zeigt _layout.tsx direkt (auth)-Group wenn kein User.
- assertVisible:
text: "E-Mail"
# E-Mail-Feld benutzt placeholder-Text "E-Mail" (auth.emailPlaceholder in de.json).
# Kein testID gesetzt -> text-Selektor auf Placeholder ist stabiler als Index.
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
# Submit-Button: Text "Anmelden" (auth.signin). Keine testID vorhanden.
- tapOn:
text: "Anmelden"
# Nach erfolgreichem Login landet der User auf dem Home-Feed ((app)-Group).
# ComposeCard ist das erste sichtbare Element nach AppHeader.
# Wir pruefen auf den App-Header (AppName via t('appHeader.appName')).
# Alternativ koennte hier auf einen Post-Feed-Element gewartet werden.
- waitForAnimationToEnd:
timeout: 8000
# Sanity: Dropdown-Menu-Trigger (Avatar im Header) muss sichtbar sein.
# Kein testID -> assertVisible auf AppName-Text (de.json: appHeader.appName = "ReBreak")
- assertVisible:
text: "ReBreak"

View File

@ -0,0 +1,67 @@
# community/post.yaml
# User-Journey: Login -> Home-Feed -> ComposeCard tippen -> Text eingeben -> Submit.
# Prueft: ComposeCard sichtbar, Text-Input akzeptiert Input, Submit-Button aktiv wird,
# Post-Erstellung loest keine Crash aus.
#
# HINWEIS: Post-Erstellung sendet echten API-Call an Staging-Backend.
# Test-Posts landen in der DB. Clean-up manuell oder via Service-Role-Delete noetig.
#
# Pre-requisite: App installiert, E2E_TEST_USER hat einen gueltigen Account auf Staging.
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 8000
# --- Home-Feed ---
- assertVisible:
text: "ReBreak"
# ComposeCard: TextInput mit placeholder t('community.compose_placeholder') = "Was bewegt dich gerade?"
# (de.json Z.602). Tippen auf den Placeholder-Bereich oeffnet den Compose-Modus.
- assertVisible:
text: "Was bewegt dich gerade?"
- tapOn:
text: "Was bewegt dich gerade?"
- waitForAnimationToEnd:
timeout: 1000
# Text eingeben
- inputText: "E2E Test-Post vom automatisierten Maestro-Flow. Bitte ignorieren."
# Nach Text-Eingabe erscheinen Actions (showActions = focused || content.length > 0).
# Submit-Button zeigt t('community.share') = "Teilen" (de.json).
- assertVisible:
text: "Teilen"
# Submit ausfuehren
- tapOn:
text: "Teilen"
# Nach erfolgreichem Post: ComposeCard resettet (content leer, Actions weg).
# Community-Query wird invalidiert, Feed refreshed.
# Wir pruefen dass der Compose-Input wieder im Idle-Zustand ist (Placeholder sichtbar).
- waitForAnimationToEnd:
timeout: 6000
- assertVisible:
text: "Was bewegt dich gerade?"

View File

@ -0,0 +1,69 @@
# profile/view-profile.yaml
# User-Journey: Login -> Home -> Header-Dropdown -> Profil antippen -> ProfileScreen sichtbar.
# Prueft: Profil-Navigation funktioniert, ProfileHeader mit Nickname + Plan-Badge sichtbar,
# StatsBar geladen (Posts/Followers/Domains-Zahlen sichtbar).
#
# Pre-requisite: App installiert, E2E_TEST_USER hat einen gueltigen Account auf Staging.
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 8000
# --- Home-Screen ---
- assertVisible:
text: "ReBreak"
# Header-Dropdown oeffnen via Avatar-Tap (obere rechte Ecke).
# TODO: testID="header-avatar-btn" in AppHeader ergaenzen fuer stabilen Selektor.
- tapOn:
point: "93%, 6%"
- waitForAnimationToEnd:
timeout: 2000
# Dropdown offen: "Profil"-Item (headerMenu.profile = "Profil", de.json Z.102)
- assertVisible:
text: "Profil"
- tapOn:
text: "Profil"
- waitForAnimationToEnd:
timeout: 4000
# ProfileScreen: AppHeader zeigt title="Profil" (hardcoded in ProfileScreen, Z.159).
# Achtung: "Profil" erscheint sowohl im AppHeader-Title als auch im Dropdown.
# Nach Navigation ist das Dropdown weg -> "Profil" im Header eindeutig.
- assertVisible:
text: "Profil"
# ProfileHeader zeigt Nickname des Test-Users.
# Test-User: claude-android-test -> Nickname = erste 2 Buchstaben als Initialen ODER
# Nickname-Text aus DB. Wir pruefen auf einen der Plan-Labels (free/pro/legend).
# Plan-Labels sind locale-unabhaengig (hartcodierte Strings in planLabel-Record):
# free -> "Free", pro -> "Pro", legend -> "Legend".
- assertVisible:
text: "Free"
# StatsBar: Zeigt hardcoded Labels "Posts" und "Follower" (StatsBar.tsx Z.105-107).
# Locale-unabhaengig — kein i18n. "Posts" erscheint nur im ProfileScreen-StatsBar-Kontext.
- assertVisible:
text: "Posts"

View File

@ -0,0 +1,76 @@
# urge/start-session.yaml
# User-Journey: Login -> Home -> Header-Dropdown oeffnen -> SOS tippen -> Urge/Lyra-Screen laden.
# Prueft: SOS-Einstiegspunkt im HeaderDropdownMenu ist sichtbar und navigiert korrekt.
#
# Pre-requisite: App installiert, E2E_TEST_USER eingeloggt (oder clearState + frischer Login).
# Env-Vars: E2E_TEST_USER, E2E_TEST_PASSWORD
appId: org.rebreak.app
---
- launchApp:
clearState: true
- waitForAnimationToEnd:
timeout: 5000
# --- Auth ---
- assertVisible:
text: "E-Mail"
- tapOn:
text: "E-Mail"
- inputText: ${E2E_TEST_USER}@rebreak.internal
- tapOn:
text: "Passwort"
- inputText: ${E2E_TEST_PASSWORD}
- tapOn:
text: "Anmelden"
- waitForAnimationToEnd:
timeout: 8000
# --- Home-Screen erreicht ---
- assertVisible:
text: "ReBreak"
# Avatar-Button (Header rechts) oeffnet das HeaderDropdownMenu (Modal).
# Der Avatar hat keinen testID -> tippen auf den Initials-Text oder Avatar-Bereich.
# Maestro kann auf accessibilityLabel matchen, falls gesetzt. Aktuell nicht gesetzt.
# Robustester Weg: tapOn text des SOS-Labels direkt wenn sichtbar, sonst Avatar-Tap first.
# Avatar ist ein Pressable ohne Text -> swipeRight oder koordinaten-basierter Tap waere
# fragil. Wir tippen stattdessen auf den bekannten Notifikationen-Button (links vom Avatar)
# um sicherzugehen dass wir im richtigen Header sind, dann auf den SOS-Button.
# Seit HeaderDropdownMenu kein accessibilityLabel hat: tapOn text "SOS" direkt nach Modal-Open.
# Schritt 1: Avatar-Pressable. Kein Label -> tippen auf "ReBreak" Text-Bereich funktioniert
# nicht (Text != Button). Wir nutzen einen direkten tapOn auf "SOS" welcher nur im
# geoffneten Dropdown erscheint. Dafuer muss das Dropdown erst offen sein.
# Da kein testID/label: Nutze point-tap am oberen-rechten Rand wo Avatar sitzt.
# HINWEIS fuer den User: Avatar-Button braucht accessibilityLabel="open-menu" damit
# dieser Tap stabiler wird. TODO: testID="header-avatar-btn" in AppHeader ergaenzen.
- tapOn:
point: "93%, 6%"
- waitForAnimationToEnd:
timeout: 2000
# Dropdown ist jetzt offen. SOS-Label ist "SOS" (appHeader.sosLabel, de.json Z.94).
- assertVisible:
text: "SOS"
- tapOn:
text: "SOS"
- waitForAnimationToEnd:
timeout: 6000
# Urge/SOS-Screen: Die RiveAvatar + initial Lyra-Nachricht werden geladen.
# Wir pruefen auf den Text-Input am unteren Rand (Urge-Screen hat einen Chat-Input).
# Kein testID -> assertVisible auf etwas das nur im SOS-Screen existiert.
# Der SOS-Screen hat keine eindeutige Headline. Wir pruefen den Input (placeholder).
# urge.tsx: placeholder aus t('urge.inputPlaceholder') oder aehnlich.
# Sicherer Fallback: waitForAnimationToEnd und dann kein Crash = Screen geladen.
- waitForAnimationToEnd:
timeout: 4000
# Urge-Screen: Chat-Input hat Placeholder t('coach.placeholder') = "Was beschaeftigt dich?"
# (de.json Z.145). Dieser Text existiert nur auf dem SOS/Urge-Screen.
- assertVisible:
text: "Was beschäftigt dich?"

View File

@ -94,7 +94,6 @@ function NotificationRow({
onPress={onPress} onPress={onPress}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
backgroundColor: isUnread ? '#fff7ed' : '#fff',
})} })}
> >
<View <View
@ -105,6 +104,7 @@ function NotificationRow({
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#f5f5f5', borderBottomColor: '#f5f5f5',
backgroundColor: isUnread ? '#fff7ed' : '#fff',
}} }}
> >
{/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */} {/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */}

View File

@ -5,6 +5,7 @@ import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import * as SplashScreen from 'expo-splash-screen'; import * as SplashScreen from 'expo-splash-screen';
import { import {
useFonts, useFonts,
@ -153,9 +154,11 @@ export default function RootLayout() {
return ( return (
<GestureHandlerRootView style={{ flex: 1 }}> <GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<SafeAreaProvider> <ActionSheetProvider>
<RootLayoutInner /> <SafeAreaProvider>
</SafeAreaProvider> <RootLayoutInner />
</SafeAreaProvider>
</ActionSheetProvider>
</QueryClientProvider> </QueryClientProvider>
</GestureHandlerRootView> </GestureHandlerRootView>
); );

View File

@ -36,14 +36,17 @@ export default function DebugScreen() {
onPress={() => router.back()} onPress={() => router.back()}
hitSlop={8} hitSlop={8}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
})}
>
<View style={{
width: 40, width: 40,
height: 40, height: 40,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
opacity: pressed ? 0.6 : 1, }}>
})} <Ionicons name="chevron-back" size={26} color={colors.text} />
> </View>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</Pressable> </Pressable>
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}> <Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
Debug Debug

View File

@ -86,18 +86,21 @@ export default function GamesScreen() {
onPress={() => exit()} onPress={() => exit()}
hitSlop={10} hitSlop={10}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
})}
>
<View style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 6, paddingVertical: 6,
opacity: pressed ? 0.6 : 1, }}>
})} <Ionicons name="chevron-back" size={22} color={colors.text} />
> <Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
<Ionicons name="chevron-back" size={22} color={colors.text} /> {t('games.back_to_picker')}
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.text }}> </Text>
{t('games.back_to_picker')} </View>
</Text>
</Pressable> </Pressable>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}> <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t(GAME_META.find((g) => g.id === active)!.titleKey)} {t(GAME_META.find((g) => g.id === active)!.titleKey)}
@ -141,14 +144,17 @@ export default function GamesScreen() {
onPress={() => router.back()} onPress={() => router.back()}
hitSlop={8} hitSlop={8}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
})}
>
<View style={{
width: 40, width: 40,
height: 40, height: 40,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
opacity: pressed ? 0.6 : 1, }}>
})} <Ionicons name="chevron-back" size={26} color={colors.text} />
> </View>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</Pressable> </Pressable>
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}> <Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
{t('games.title')} {t('games.title')}

View File

@ -120,10 +120,7 @@ export default function ForeignProfileScreen() {
<Pressable <Pressable
onPress={() => router.back()} onPress={() => router.back()}
hitSlop={8} hitSlop={8}
style={({ pressed }) => ({ style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, padding: 8 })}
opacity: pressed ? 0.5 : 1,
padding: 8,
})}
> >
<Ionicons name="chevron-back" size={22} color={colors.text} /> <Ionicons name="chevron-back" size={22} color={colors.text} />
</Pressable> </Pressable>
@ -213,23 +210,26 @@ export default function ForeignProfileScreen() {
style={({ pressed }) => ({ style={({ pressed }) => ({
flex: 1, flex: 1,
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
})}
>
<View style={{
paddingVertical: 11, paddingVertical: 11,
borderRadius: 12, borderRadius: 12,
backgroundColor: isFollowing ? '#f5f5f5' : colors.brandOrange, backgroundColor: isFollowing ? '#f5f5f5' : colors.brandOrange,
borderWidth: 1, borderWidth: 1,
borderColor: isFollowing ? '#e5e5e5' : colors.brandOrange, borderColor: isFollowing ? '#e5e5e5' : colors.brandOrange,
alignItems: 'center', alignItems: 'center',
})} }}>
> <Text
<Text style={{
style={{ fontSize: 13,
fontSize: 13, color: isFollowing ? colors.text : '#ffffff',
color: isFollowing ? colors.text : '#ffffff', fontFamily: 'Nunito_600SemiBold',
fontFamily: 'Nunito_600SemiBold', }}
}} >
> {isFollowing ? 'Folge ich' : 'Folgen'}
{isFollowing ? 'Folge ich' : 'Folgen'} </Text>
</Text> </View>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => { onPress={() => {
@ -239,14 +239,16 @@ export default function ForeignProfileScreen() {
style={({ pressed }) => ({ style={({ pressed }) => ({
flex: 1, flex: 1,
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
})}
>
<View style={{
paddingVertical: 11, paddingVertical: 11,
borderRadius: 12, borderRadius: 12,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
borderWidth: 1, borderWidth: 1,
borderColor: '#e5e5e5', borderColor: '#e5e5e5',
alignItems: 'center', alignItems: 'center',
})} }}>
>
<Text <Text
style={{ style={{
fontSize: 13, fontSize: 13,
@ -256,6 +258,7 @@ export default function ForeignProfileScreen() {
> >
Nachricht Nachricht
</Text> </Text>
</View>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@ -87,20 +87,31 @@ const DUMMY_DEMOGRAPHICS: Demographics = {
birthYear: 1989, birthYear: 1989,
gender: 'diverse', gender: 'diverse',
maritalStatus: null, maritalStatus: null,
profession: null, employmentStatus: null,
shiftWork: null,
industry: null,
jobTenure: null,
bundesland: 'BY', bundesland: 'BY',
city: null, city: null,
}; };
function isDemographicsComplete(d: Demographics): boolean { function isDemographicsComplete(d: Demographics): boolean {
return ( const base =
d.birthYear !== null && d.birthYear !== null &&
!!d.gender && !!d.gender &&
!!d.maritalStatus && !!d.maritalStatus &&
!!d.profession && !!d.employmentStatus &&
!!d.bundesland && !!d.bundesland &&
!!d.city !!d.city;
); if (!base) return false;
const status = d.employmentStatus!;
const needsShift = ['employed', 'self_employed'].includes(status);
const needsIndustry = ['employed', 'self_employed', 'in_training'].includes(status);
const needsTenure = ['employed', 'self_employed'].includes(status);
if (needsShift && d.shiftWork === null) return false;
if (needsIndustry && !d.industry) return false;
if (needsTenure && !d.jobTenure) return false;
return true;
} }
export default function ProfileScreen() { export default function ProfileScreen() {
@ -155,7 +166,7 @@ export default function ProfileScreen() {
} }
return ( return (
<View style={{ flex: 1, backgroundColor: '#ffffff' }}> <View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<AppHeader showBack title="Profil" /> <AppHeader showBack title="Profil" />
<ScrollView <ScrollView
ref={scrollViewRef} ref={scrollViewRef}
@ -170,17 +181,16 @@ export default function ProfileScreen() {
plan={profile.plan} plan={profile.plan}
memberSince={profile.memberSince} memberSince={profile.memberSince}
provider={profile.provider} provider={profile.provider}
demographicsComplete={demoComplete}
showDemographicsHint={!demoComplete} showDemographicsHint={!demoComplete}
onDemographicsHintPress={openDemographics} onDemographicsHintPress={openDemographics}
onEditAvatar={() => { onEditAvatar={() => {
// TODO Phase C: AvatarPickerSheet (preset-grid + custom-upload via expo-image-picker)
Alert.alert( Alert.alert(
'Avatar bearbeiten', 'Avatar bearbeiten',
'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.', 'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.',
); );
}} }}
onEditNickname={() => { onEditNickname={() => {
// TODO Phase C: NicknameEditSheet → PATCH /api/auth/me
Alert.alert( Alert.alert(
'Nickname bearbeiten', 'Nickname bearbeiten',
'Inline-Edit + Save kommt in der nächsten Iteration.', 'Inline-Edit + Save kommt in der nächsten Iteration.',
@ -213,8 +223,6 @@ export default function ProfileScreen() {
/> />
</View> </View>
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
{showDigaBanner ? ( {showDigaBanner ? (
<DigaMissionBanner <DigaMissionBanner
onDismiss={() => { onDismiss={() => {
@ -261,6 +269,9 @@ export default function ProfileScreen() {
/> />
</View> </View>
{/* ApprovedDomains ans Ende — User-Direktive 2026-05-08 */}
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
<View style={{ height: 24 }} /> <View style={{ height: 24 }} />
<Text <Text
style={{ style={{

View File

@ -1,17 +1,16 @@
import { import {
Alert, Alert,
Animated,
Modal,
Platform, Platform,
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
View, View,
} from 'react-native'; } from 'react-native';
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useNativeActionSheet } from '../lib/useNativeActionSheet';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { colors } from '../lib/theme'; import { colors } from '../lib/theme';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
@ -20,123 +19,12 @@ import { useLanguageStore, type AppLanguage } from '../stores/language';
import { useUserPlan } from '../hooks/useUserPlan'; import { useUserPlan } from '../hooks/useUserPlan';
import { AppHeader } from '../components/AppHeader'; import { AppHeader } from '../components/AppHeader';
// ─── Picker Sheet ────────────────────────────────────────────────────────── // ─── Settings Screen ───────────────────────────────────────────────────────
type PickerOption<T extends string> = { value: T; label: string }; type PickerOption<T extends string> = { value: T; label: string };
function PickerSheet<T extends string>({
visible,
title,
options,
selected,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: PickerOption<T>[];
selected: T;
onSelect: (v: T) => void;
onClose: () => void;
}) {
const translateY = useRef(new Animated.Value(300)).current;
useEffect(() => {
if (visible) {
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
damping: 22,
stiffness: 280,
}).start();
} else {
Animated.timing(translateY, {
toValue: 300,
duration: 180,
useNativeDriver: true,
}).start();
}
}, [visible]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
<Pressable
onPress={onClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.3)', justifyContent: 'flex-end' }}
>
<Animated.View
onStartShouldSetResponder={() => true}
style={{
backgroundColor: '#fff',
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
paddingBottom: 34,
transform: [{ translateY }],
}}
>
<View
style={{
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#e5e5e5',
alignSelf: 'center',
marginTop: 10,
marginBottom: 14,
}}
/>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center',
marginBottom: 12,
}}
>
{title}
</Text>
{options.map((opt) => (
<Pressable
key={opt.value}
onPress={() => {
onSelect(opt.value);
onClose();
}}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 14,
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
})}
>
<Text
style={{
fontSize: 15,
fontFamily: opt.value === selected ? 'Nunito_700Bold' : 'Nunito_400Regular',
color: opt.value === selected ? colors.brandOrange : '#0a0a0a',
}}
>
{opt.label}
</Text>
{opt.value === selected && (
<Ionicons name="checkmark" size={18} color={colors.brandOrange} />
)}
</Pressable>
))}
</Animated.View>
</Pressable>
</Modal>
);
}
// ─── Settings Screen ───────────────────────────────────────────────────────
type SectionRow = { type SectionRow = {
icon: React.ComponentProps<typeof Ionicons>['name']; icon: React.ComponentProps<typeof Ionicons>['name'];
iconColor: string;
label: string; label: string;
sublabel: string; sublabel: string;
soon?: boolean; soon?: boolean;
@ -159,10 +47,7 @@ export default function SettingsScreen() {
const { mode: themeMode, setMode: setThemeMode } = useThemeStore(); const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
const { language, setLanguage } = useLanguageStore(); const { language, setLanguage } = useLanguageStore();
const { plan } = useUserPlan(); const { plan } = useUserPlan();
const { showActionSheetWithOptions } = useNativeActionSheet();
const [themePickerOpen, setThemePickerOpen] = useState(false);
const [langPickerOpen, setLangPickerOpen] = useState(false);
const [voicePickerOpen, setVoicePickerOpen] = useState(false);
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later) // Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
// Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId. // Backend endpoint PATCH /api/profile/me/demographics does NOT accept lyraVoiceId.
@ -170,6 +55,25 @@ export default function SettingsScreen() {
// For now: picker is wired to local state only, changes are NOT persisted. // For now: picker is wired to local state only, changes are NOT persisted.
const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL'); const [selectedVoice, setSelectedVoice] = useState('EXAVITQu4vr4xnSDxMaL');
function pickFromOptions<T extends string>(
title: string,
options: PickerOption<T>[],
onPick: (value: T) => void,
) {
const labels = options.map((o) => o.label);
showActionSheetWithOptions(
{
title,
options: [...labels, t('common.cancel')],
cancelButtonIndex: labels.length,
},
(idx) => {
if (idx === undefined || idx === labels.length) return;
onPick(options[idx].value);
},
);
}
async function handleSignOut() { async function handleSignOut() {
Alert.alert(t('auth.signOut'), '', [ Alert.alert(t('auth.signOut'), '', [
{ text: t('common.cancel'), style: 'cancel' }, { text: t('common.cancel'), style: 'cancel' },
@ -214,45 +118,30 @@ export default function SettingsScreen() {
voiceOptions.find((v) => v.value === selectedVoice)?.label ?? t('settings.lyra_voice_sarah'); voiceOptions.find((v) => v.value === selectedVoice)?.label ?? t('settings.lyra_voice_sarah');
const sections: Section[] = [ const sections: Section[] = [
{ // Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt
key: 'profile',
title: t('settings.section_profile'),
rows: [
{
icon: 'person-outline',
iconColor: '#6366f1',
label: t('settings.profile_edit'),
sublabel: t('settings.profile_edit_desc'),
soon: true,
},
{
icon: 'image-outline',
iconColor: '#6366f1',
label: t('settings.profile_avatar'),
sublabel: t('settings.profile_avatar_desc'),
soon: true,
},
],
},
{ {
key: 'theme', key: 'theme',
title: t('settings.section_theme'), title: t('settings.section_theme'),
rows: [ rows: [
{ {
icon: 'color-palette-outline', icon: 'color-palette-outline',
iconColor: '#a78bfa',
label: t('settings.theme'), label: t('settings.theme'),
sublabel: t('settings.theme_desc'), sublabel: t('settings.theme_desc'),
value: themeLabel, value: themeLabel,
onPress: () => setThemePickerOpen(true), onPress: () =>
pickFromOptions<ThemeMode>(t('settings.theme'), themeOptions, (v) =>
setThemeMode(v),
),
}, },
{ {
icon: 'language-outline', icon: 'language-outline',
iconColor: '#a78bfa',
label: t('settings.language'), label: t('settings.language'),
sublabel: t('settings.language_desc'), sublabel: t('settings.language_desc'),
value: language === 'de' ? t('settings.language_de') : t('settings.language_en'), value: language === 'de' ? t('settings.language_de') : t('settings.language_en'),
onPress: () => setLangPickerOpen(true), onPress: () =>
pickFromOptions<AppLanguage>(t('settings.language'), langOptions, (v) =>
setLanguage(v),
),
}, },
], ],
}, },
@ -262,14 +151,12 @@ export default function SettingsScreen() {
rows: [ rows: [
{ {
icon: 'notifications-outline', icon: 'notifications-outline',
iconColor: '#2563eb',
label: t('settings.notifications_push'), label: t('settings.notifications_push'),
sublabel: t('settings.notifications_push_desc'), sublabel: t('settings.notifications_push_desc'),
soon: true, soon: true,
}, },
{ {
icon: 'flame-outline', icon: 'flame-outline',
iconColor: '#f97316',
label: t('settings.notifications_streak'), label: t('settings.notifications_streak'),
sublabel: t('settings.notifications_streak_desc'), sublabel: t('settings.notifications_streak_desc'),
soon: true, soon: true,
@ -282,14 +169,12 @@ export default function SettingsScreen() {
rows: [ rows: [
{ {
icon: 'phone-portrait-outline', icon: 'phone-portrait-outline',
iconColor: '#16a34a',
label: t('settings.devices'), label: t('settings.devices'),
sublabel: t('settings.devices_desc'), sublabel: t('settings.devices_desc'),
soon: true, soon: true,
}, },
{ {
icon: 'star-outline', icon: 'star-outline',
iconColor: colors.brandOrange,
label: t('settings.subscription'), label: t('settings.subscription'),
sublabel: t('settings.subscription_desc'), sublabel: t('settings.subscription_desc'),
soon: true, soon: true,
@ -302,7 +187,6 @@ export default function SettingsScreen() {
rows: [ rows: [
{ {
icon: 'mic-outline', icon: 'mic-outline',
iconColor: '#ec4899',
label: t('settings.lyra_voice'), label: t('settings.lyra_voice'),
sublabel: sublabel:
plan === 'legend' plan === 'legend'
@ -311,7 +195,15 @@ export default function SettingsScreen() {
value: plan === 'legend' ? selectedVoiceName : undefined, value: plan === 'legend' ? selectedVoiceName : undefined,
// Voice picker is wired but changes are local-only until // Voice picker is wired but changes are local-only until
// PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent. // PATCH /api/profile/me/lyra-voice endpoint is added by backend-agent.
onPress: plan === 'legend' ? () => setVoicePickerOpen(true) : undefined, onPress:
plan === 'legend'
? () =>
pickFromOptions<string>(
t('settings.lyra_voice'),
voiceOptions,
(v) => setSelectedVoice(v),
)
: undefined,
soon: plan !== 'legend', soon: plan !== 'legend',
}, },
], ],
@ -322,14 +214,12 @@ export default function SettingsScreen() {
rows: [ rows: [
{ {
icon: 'log-out-outline', icon: 'log-out-outline',
iconColor: colors.textMuted,
label: t('settings.sign_out'), label: t('settings.sign_out'),
sublabel: '', sublabel: '',
onPress: handleSignOut, onPress: handleSignOut,
}, },
{ {
icon: 'trash-outline', icon: 'trash-outline',
iconColor: colors.error,
label: t('settings.delete_account'), label: t('settings.delete_account'),
sublabel: t('settings.delete_desc'), sublabel: t('settings.delete_desc'),
destructive: true, destructive: true,
@ -346,14 +236,12 @@ export default function SettingsScreen() {
rows: [ rows: [
{ {
icon: 'bug-outline', icon: 'bug-outline',
iconColor: '#737373',
label: t('settings.debug_llm'), label: t('settings.debug_llm'),
sublabel: t('settings.debug_llm_desc'), sublabel: t('settings.debug_llm_desc'),
soon: true, soon: true,
}, },
{ {
icon: 'volume-high-outline', icon: 'volume-high-outline',
iconColor: '#737373',
label: t('settings.debug_tts'), label: t('settings.debug_tts'),
sublabel: t('settings.debug_tts_desc'), sublabel: t('settings.debug_tts_desc'),
soon: true, soon: true,
@ -363,7 +251,7 @@ export default function SettingsScreen() {
} }
return ( return (
<View style={{ flex: 1, backgroundColor: colors.bg }}> <View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<AppHeader showBack title={t('settings.title')} /> <AppHeader showBack title={t('settings.title')} />
<ScrollView <ScrollView
@ -392,11 +280,14 @@ export default function SettingsScreen() {
</Text> </Text>
<View <View
style={{ style={{
backgroundColor: colors.surface, backgroundColor: '#ffffff',
borderRadius: 14, borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
overflow: 'hidden', overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04,
shadowRadius: 3,
elevation: 1,
}} }}
> >
{section.rows.map((row, i) => ( {section.rows.map((row, i) => (
@ -405,96 +296,83 @@ export default function SettingsScreen() {
onPress={row.soon ? undefined : row.onPress} onPress={row.soon ? undefined : row.onPress}
disabled={row.soon} disabled={row.soon}
style={({ pressed }) => ({ style={({ pressed }) => ({
width: '100%',
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 14,
paddingVertical: 12,
minHeight: 56,
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.04)',
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1, opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
backgroundColor: pressed && !row.soon ? '#f5f5f5' : 'transparent',
})} })}
> >
<View <View
style={{ style={{
width: 36, flexDirection: 'row',
height: 36,
borderRadius: 10,
backgroundColor: row.iconColor + '18',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', gap: 12,
flexShrink: 0, paddingHorizontal: 14,
flexGrow: 0, paddingVertical: 12,
minHeight: 56,
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.04)',
}} }}
> >
<Ionicons <Ionicons
name={row.icon} name={row.icon}
size={18} size={18}
color={row.destructive ? colors.error : row.iconColor} color={row.destructive ? colors.error : colors.textMuted}
/> />
</View> <View style={{ flex: 1 }}>
<View style={{ flex: 1, minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
style={{
fontSize: 15,
color: row.destructive ? colors.error : colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{row.label}
</Text>
{row.sublabel ? (
<Text <Text
numberOfLines={1} numberOfLines={1}
style={{ style={{
fontSize: 12, fontSize: 15,
color: colors.textMuted, color: row.destructive ? colors.error : colors.text,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_600SemiBold',
marginTop: 2,
}} }}
> >
{row.sublabel} {row.label}
</Text> </Text>
) : null} {row.sublabel ? (
<Text
numberOfLines={1}
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 2,
}}
>
{row.sublabel}
</Text>
) : null}
</View>
{row.soon ? (
<Text
style={{
fontSize: 10,
color: '#a3a3a3',
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
{t('settings.soon_badge')}
</Text>
) : row.value ? (
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginLeft: 4,
}}
numberOfLines={1}
>
{row.value}
</Text>
) : (
<Ionicons
name="chevron-forward"
size={16}
color="#d4d4d8"
/>
)}
</View> </View>
{row.soon ? (
<Text
style={{
fontSize: 10,
color: '#a3a3a3',
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
flexShrink: 0,
}}
>
{t('settings.soon_badge')}
</Text>
) : row.value ? (
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
flexShrink: 0,
marginLeft: 4,
}}
numberOfLines={1}
>
{row.value}
</Text>
) : (
<Ionicons
name="chevron-forward"
size={16}
color="#d4d4d8"
style={{ flexShrink: 0 }}
/>
)}
</Pressable> </Pressable>
))} ))}
</View> </View>
@ -526,33 +404,6 @@ export default function SettingsScreen() {
{Platform.OS} {Platform.OS}
</Text> </Text>
</ScrollView> </ScrollView>
<PickerSheet
visible={themePickerOpen}
title={t('settings.theme_picker_title')}
options={themeOptions}
selected={themeMode}
onSelect={setThemeMode}
onClose={() => setThemePickerOpen(false)}
/>
<PickerSheet
visible={langPickerOpen}
title={t('settings.language_picker_title')}
options={langOptions}
selected={language}
onSelect={setLanguage}
onClose={() => setLangPickerOpen(false)}
/>
<PickerSheet
visible={voicePickerOpen}
title={t('settings.lyra_voice_picker_title')}
options={voiceOptions}
selected={selectedVoice}
onSelect={setSelectedVoice}
onClose={() => setVoicePickerOpen(false)}
/>
</View> </View>
); );
} }

View File

@ -146,7 +146,7 @@ export function ComposeCard({ onPosted }: Props) {
onPress={pickImage} onPress={pickImage}
android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }} android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }}
className="flex-row items-center gap-1.5 px-2" className="flex-row items-center gap-1.5 px-2"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, height: 44, alignItems: 'center', justifyContent: 'center' })} style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, height: 44 })}
> >
<Ionicons name="image-outline" size={22} color="#737373" /> <Ionicons name="image-outline" size={22} color="#737373" />
<Text className="text-sm text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>{t('community.image')}</Text> <Text className="text-sm text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>{t('community.image')}</Text>
@ -163,7 +163,7 @@ export function ComposeCard({ onPosted }: Props) {
<Pressable <Pressable
onPressIn={() => { if (!content.trim() || posting) return; submit(); }} onPressIn={() => { if (!content.trim() || posting) return; submit(); }}
disabled={!content.trim() || posting} disabled={!content.trim() || posting}
className="bg-rebreak-500 items-center justify-center rounded-full px-5 h-11" className="bg-rebreak-500 items-center justify-center rounded-full px-5 h-8"
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed || !content.trim() || posting ? 0.5 : 1, opacity: pressed || !content.trim() || posting ? 0.5 : 1,
})} })}

View File

@ -240,7 +240,6 @@ function NotificationRow({
onPress={onPress} onPress={onPress}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.65 : 1, opacity: pressed ? 0.65 : 1,
backgroundColor: isUnread ? '#fff7ed' : '#ffffff',
})} })}
> >
<View <View
@ -251,6 +250,7 @@ function NotificationRow({
paddingVertical: 11, paddingVertical: 11,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: '#f5f5f5', borderBottomColor: '#f5f5f5',
backgroundColor: isUnread ? '#fff7ed' : '#ffffff',
}} }}
> >
{/* Avatar-Logik: {/* Avatar-Logik:

View File

@ -0,0 +1,244 @@
/**
* OptionsBottomSheet Pixel-perfect iOS UIAlertController.actionSheet replication.
*
* Pattern: 2 separate Cards (Options + Cancel) mit Gap dazwischen, horizontal margin,
* native iOS-Typografie + Spacing. Auf iOS 26 funktioniert das native ActionSheetIOS
* nicht mehr klassisch (centered popover) eigene Implementation für konsistenten
* bottom-up sheet-look auf jeder iOS-Version.
*
* Use für:
* - Geschlecht (3), kurze Auswahl-Listen, Confirm-Dialogs mit destructive-Action
* Use NICHT für:
* - Lange Listen (>7) WheelPickerModal
* - Free-text input eigene Sheet (z.B. AddDomainSheet)
*/
import { useEffect, useRef } from 'react';
import {
Modal,
View,
Text,
Pressable,
Animated,
Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { colors } from '../lib/theme';
type Option<T> = {
value: T;
label: string;
/** Rendert in roter Farbe (für „Löschen", „Reset" etc) */
destructive?: boolean;
};
type Props<T extends string | number> = {
visible: boolean;
title?: string;
message?: string;
options: Option<T>[];
value?: T | null;
onSelect: (value: T) => void;
onClose: () => void;
};
export function OptionsBottomSheet<T extends string | number>({
visible,
title,
message,
options,
value,
onSelect,
onClose,
}: Props<T>) {
const insets = useSafeAreaInsets();
const translateY = useRef(new Animated.Value(400)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
translateY.setValue(400);
backdropOpacity.setValue(0);
Animated.parallel([
Animated.timing(translateY, {
toValue: 0,
duration: 280,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
}
}, [visible, translateY, backdropOpacity]);
function close() {
Animated.parallel([
Animated.timing(translateY, {
toValue: 400,
duration: 220,
easing: Easing.in(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(backdropOpacity, {
toValue: 0,
duration: 180,
useNativeDriver: true,
}),
]).start(() => {
onClose();
});
}
const hasHeader = !!(title || message);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={close}>
{/* Backdrop */}
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={close} />
</Animated.View>
{/* Sheet — bottom-aligned, horizontal margin, 2 separate cards */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 10,
paddingBottom: Math.max(insets.bottom, 10),
transform: [{ translateY }],
}}
>
{/* Options-Card */}
<View
style={{
backgroundColor: 'rgba(250,250,252,0.97)',
borderRadius: 14,
overflow: 'hidden',
marginBottom: 8,
}}
>
{hasHeader ? (
<View
style={{
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
borderBottomColor: 'rgba(60,60,67,0.36)',
alignItems: 'center',
}}
>
{title ? (
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
>
{title}
</Text>
) : null}
{message ? (
<Text
style={{
marginTop: 4,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
lineHeight: 16,
}}
>
{message}
</Text>
) : null}
</View>
) : null}
{options.map((opt, idx) => {
const isLast = idx === options.length - 1;
const isSelected =
value !== null && value !== undefined && opt.value === value;
return (
<Pressable
key={String(opt.value)}
onPress={() => {
onSelect(opt.value);
close();
}}
style={({ pressed }) => ({
backgroundColor: pressed ? 'rgba(0,0,0,0.06)' : 'transparent',
})}
>
<View
style={{
paddingVertical: 17,
paddingHorizontal: 16,
borderBottomWidth: isLast ? 0 : 0.5,
borderBottomColor: 'rgba(60,60,67,0.36)',
alignItems: 'center',
}}
>
<Text
style={{
fontSize: 20,
color: opt.destructive ? colors.error : colors.brandOrange,
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
textAlign: 'center',
}}
>
{opt.label}
</Text>
</View>
</Pressable>
);
})}
</View>
{/* Cancel-Card — separat, bold */}
<Pressable onPress={close}>
{({ pressed }) => (
<View
style={{
backgroundColor: pressed
? 'rgba(255,255,255,0.85)'
: 'rgba(250,250,252,0.97)',
borderRadius: 14,
paddingVertical: 17,
paddingHorizontal: 16,
alignItems: 'center',
}}
>
<Text
style={{
fontSize: 20,
color: colors.brandOrange,
fontFamily: 'Nunito_700Bold',
textAlign: 'center',
}}
>
Abbrechen
</Text>
</View>
)}
</Pressable>
</Animated.View>
</Modal>
);
}

View File

@ -396,20 +396,23 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
onPress={submit} onPress={submit}
disabled={!text.trim() || submitting} disabled={!text.trim() || submitting}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed || !text.trim() || submitting ? 0.5 : 1,
})}
>
<View style={{
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20, borderRadius: 20,
backgroundColor: colors.brandOrange, backgroundColor: colors.brandOrange,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
opacity: pressed || !text.trim() || submitting ? 0.5 : 1, }}>
})}
>
{submitting ? ( {submitting ? (
<ActivityIndicator size="small" color="#fff" /> <ActivityIndicator size="small" color="#fff" />
) : ( ) : (
<Ionicons name="paper-plane" size={16} color="#fff" /> <Ionicons name="paper-plane" size={16} color="#fff" />
)} )}
</View>
</Pressable> </Pressable>
</View> </View>
</Animated.View> </Animated.View>

View File

@ -0,0 +1,145 @@
/**
* WheelPickerModal iOS-native UIPickerView style wheel via @react-native-picker/picker.
*
* Pattern:
* 1. User taps a row setVisible(true)
* 2. Modal slides up bottom-sheet with native iOS wheel + Done/Cancel
* 3. Wheel scrolls through options, current selection highlighted
* 4. User taps "Fertig" onSelect(value), modal closes
* 5. User taps "Abbrechen" or outside modal closes without change
*
* Use für: lange Listen (Geburtsjahr 91 items, Bundesland 16, Stadt 30-50/Bundesland).
* Für kurze Listen (3-7 items) bleibt ActionSheet besser (siehe useNativeActionSheet).
*/
import { useEffect, useState } from 'react';
import { Modal, View, Text, Pressable } from 'react-native';
import { Picker } from '@react-native-picker/picker';
import { colors } from '../lib/theme';
type Option<T> = { value: T; label: string };
type Props<T extends string | number> = {
visible: boolean;
title: string;
options: Option<T>[];
value: T | null;
onSelect: (value: T) => void;
onClose: () => void;
};
export function WheelPickerModal<T extends string | number>({
visible,
title,
options,
value,
onSelect,
onClose,
}: Props<T>) {
// Tracks the wheel's current selection (separate from confirmed value).
// Initialized from `value` prop on each open.
const [tempValue, setTempValue] = useState<T | null>(value);
useEffect(() => {
if (visible) {
// First-open default: if no current value, pick first option.
setTempValue(value ?? options[0]?.value ?? null);
}
}, [visible, value, options]);
function handleConfirm() {
if (tempValue !== null && tempValue !== undefined) {
onSelect(tempValue);
}
onClose();
}
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={() => {}}>
<View
style={{
backgroundColor: '#ffffff',
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingBottom: 24,
}}
>
{/* Header: Cancel / Title / Done */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#e5e5e5',
}}
>
<Pressable onPress={onClose} hitSlop={10}>
<Text
style={{
fontSize: 15,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
Abbrechen
</Text>
</Pressable>
<Text
style={{
fontSize: 15,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{title}
</Text>
<Pressable onPress={handleConfirm} hitSlop={10}>
<Text
style={{
fontSize: 15,
color: colors.brandOrange,
fontFamily: 'Nunito_700Bold',
}}
>
Fertig
</Text>
</Pressable>
</View>
{/* Wheel — native iOS UIPickerView */}
<Picker
selectedValue={tempValue ?? undefined}
onValueChange={(v) => setTempValue(v as T)}
style={{ width: '100%' }}
itemStyle={{ fontSize: 18, color: colors.text }}
>
{options.map((opt) => (
<Picker.Item
key={String(opt.value)}
label={opt.label}
value={opt.value}
/>
))}
</Picker>
</View>
</Pressable>
</Pressable>
</Modal>
);
}

View File

@ -328,21 +328,24 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
onPress={handleAdd} onPress={handleAdd}
disabled={!valid || !confirmPermanent || adding} disabled={!valid || !confirmPermanent || adding}
style={({ pressed }) => ({ style={({ pressed }) => ({
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
opacity: pressed ? 0.85 : 1, opacity: pressed ? 0.85 : 1,
marginBottom: insets.bottom > 0 ? 8 : 12, marginBottom: insets.bottom > 0 ? 8 : 12,
})} })}
> >
{adding ? ( <View style={{
<ActivityIndicator color="#fff" /> backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
) : ( borderRadius: 14,
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}> paddingVertical: 14,
{t('blocker.add_sheet_title')} alignItems: 'center',
</Text> }}>
)} {adding ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.add_sheet_title')}
</Text>
)}
</View>
</Pressable> </Pressable>
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>

View File

@ -55,13 +55,15 @@ export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
disabled={cancelling} disabled={cancelling}
hitSlop={8} hitSlop={8}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed || cancelling ? 0.7 : 1,
})}
>
<View style={{
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 8, paddingVertical: 8,
borderRadius: 12, borderRadius: 12,
backgroundColor: '#16a34a', backgroundColor: '#16a34a',
opacity: pressed || cancelling ? 0.7 : 1, }}>
})}
>
{cancelling ? ( {cancelling ? (
<ActivityIndicator size="small" color="#fff" /> <ActivityIndicator size="small" color="#fff" />
) : ( ) : (
@ -69,6 +71,7 @@ export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
{t('common.cancel')} {t('common.cancel')}
</Text> </Text>
)} )}
</View>
</Pressable> </Pressable>
</View> </View>
); );

View File

@ -139,6 +139,10 @@ export function DeactivationExplainerSheet({
<Pressable <Pressable
onPress={onBreathe} onPress={onBreathe}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.85 : 1,
})}
>
<View style={{
backgroundColor: '#16a34a', backgroundColor: '#16a34a',
borderRadius: 14, borderRadius: 14,
paddingVertical: 16, paddingVertical: 16,
@ -147,13 +151,12 @@ export function DeactivationExplainerSheet({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 10, gap: 10,
opacity: pressed ? 0.85 : 1, }}>
})} <Ionicons name="leaf" size={18} color="#fff" />
> <Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
<Ionicons name="leaf" size={18} color="#fff" /> {t('blocker.deactivation_breathe_cta')}
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}> </Text>
{t('blocker.deactivation_breathe_cta')} </View>
</Text>
</Pressable> </Pressable>
{/* Destructive secondary */} {/* Destructive secondary */}
@ -162,9 +165,9 @@ export function DeactivationExplainerSheet({
disabled={submitting} disabled={submitting}
hitSlop={8} hitSlop={8}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed || submitting ? 0.5 : 1,
alignSelf: 'center', alignSelf: 'center',
paddingVertical: 12, paddingVertical: 12,
opacity: pressed || submitting ? 0.5 : 1,
})} })}
> >
<Text <Text

View File

@ -139,6 +139,10 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
<Pressable <Pressable
onPress={onUpgradePro} onPress={onUpgradePro}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.85 : 1,
})}
>
<View style={{
backgroundColor: '#eff6ff', backgroundColor: '#eff6ff',
borderWidth: 1, borderWidth: 1,
borderColor: '#bfdbfe', borderColor: '#bfdbfe',
@ -147,17 +151,16 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
opacity: pressed ? 0.85 : 1, }}>
})} <Ionicons name="lock-closed" size={18} color="#2563eb" />
> <View style={{ flex: 1 }}>
<Ionicons name="lock-closed" size={18} color="#2563eb" /> <Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}>
<View style={{ flex: 1 }}> {t('blocker.domain_limit_title')}
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}> </Text>
{t('blocker.domain_limit_title')} <Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#3b82f6' }}>
</Text> {t('blocker.domain_limit_desc')}
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#3b82f6' }}> </Text>
{t('blocker.domain_limit_desc')} </View>
</Text>
</View> </View>
</Pressable> </Pressable>
)} )}

View File

@ -92,21 +92,24 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
onPress={onPressSettings} onPress={onPressSettings}
hitSlop={10} hitSlop={10}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
})}
accessibilityLabel={t('blocker.protection_settings_a11y')}
>
<View style={{
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05, shadowOpacity: 0.05,
shadowRadius: 2, shadowRadius: 2,
})} }}>
accessibilityLabel={t('blocker.protection_settings_a11y')} <Ionicons name="settings-outline" size={18} color="#525252" />
> </View>
<Ionicons name="settings-outline" size={18} color="#525252" />
</Pressable> </Pressable>
) : ( ) : (
<Switch <Switch

View File

@ -341,28 +341,31 @@ export function ProtectionDetailsSheet({
))} ))}
</View> </View>
{/* MEHR INFO outline button, Icon + Label nebeneinander (flex-row, NICHT col) */} {/* MEHR INFO outline button: Pressable=card, inner View=flex-row */}
<Pressable <Pressable
onPress={onRequestDeactivation} onPress={onRequestDeactivation}
style={({ pressed }) => ({ style={({ pressed }) => ({
alignSelf: 'stretch',
marginTop: 4, marginTop: 4,
opacity: pressed ? 0.75 : 1,
})}
>
<View style={{
paddingVertical: 14, paddingVertical: 14,
paddingHorizontal: 16, paddingHorizontal: 16,
borderRadius: 12, borderRadius: 12,
borderWidth: 1.5, borderWidth: 1.5,
borderColor: HERO_COLOR, borderColor: HERO_COLOR,
backgroundColor: pressed ? '#fed7aa' : '#fff7ed', backgroundColor: '#fff7ed',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: 8, gap: 8,
})} }}>
> <Ionicons name="information-circle-outline" size={18} color={HERO_COLOR} />
<Ionicons name="information-circle-outline" size={18} color={HERO_COLOR} /> <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}> {t('blocker.more_info_title')}
{t('blocker.more_info_title')} </Text>
</Text> </View>
</Pressable> </Pressable>
</ScrollView> </ScrollView>
</Animated.View> </Animated.View>
@ -670,30 +673,29 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
<Pressable <Pressable
onPress={() => setOpen((v) => !v)} onPress={() => setOpen((v) => !v)}
style={({ pressed }) => ({ style={({ pressed }) => ({
alignSelf: 'stretch', opacity: pressed ? 0.75 : 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 14,
backgroundColor: pressed ? '#fafafa' : '#fff',
})} })}
> >
<Text style={{ flex: 1, fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', lineHeight: 18, paddingRight: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'center', paddingHorizontal: 14, paddingVertical: 14 }}>
{question} <View style={{ flex: 1, paddingRight: 12 }}>
</Text> <Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', lineHeight: 18 }}>
<Animated.View {question}
style={{ </Text>
width: 28, </View>
height: 28, <Animated.View
borderRadius: 14, style={{
backgroundColor: '#f5f5f5', width: 28,
alignItems: 'center', height: 28,
justifyContent: 'center', borderRadius: 14,
transform: [{ rotate }], backgroundColor: '#f5f5f5',
}} alignItems: 'center',
> justifyContent: 'center',
<Ionicons name="chevron-down" size={16} color="#525252" /> transform: [{ rotate }],
</Animated.View> }}
>
<Ionicons name="chevron-down" size={16} color="#525252" />
</Animated.View>
</View>
</Pressable> </Pressable>
{open && ( {open && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}> <View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>

View File

@ -76,21 +76,24 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
onPress={onPressSettings} onPress={onPressSettings}
hitSlop={10} hitSlop={10}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
})}
accessibilityLabel={t('blocker.protection_settings_a11y')}
>
<View style={{
width: 36, width: 36,
height: 36, height: 36,
borderRadius: 18, borderRadius: 18,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 1 }, shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05, shadowOpacity: 0.05,
shadowRadius: 2, shadowRadius: 2,
})} }}>
accessibilityLabel={t('blocker.protection_settings_a11y')} <Ionicons name="settings-outline" size={18} color="#525252" />
> </View>
<Ionicons name="settings-outline" size={18} color="#525252" />
</Pressable> </Pressable>
</View> </View>

View File

@ -31,45 +31,49 @@ export function GameCard({
onPress={() => onPress(id)} onPress={() => onPress(id)}
style={({ pressed }) => ({ style={({ pressed }) => ({
width: '100%', width: '100%',
transform: [{ scale: pressed ? 0.97 : 1 }],
opacity: pressed ? 0.85 : 1,
})}
>
<View style={{
borderRadius: 18, borderRadius: 18,
borderWidth: 1, borderWidth: 1,
borderColor: '#e5e7eb', borderColor: '#e5e7eb',
backgroundColor: pressed ? '#f0f9ff' : '#fafafa', backgroundColor: '#fafafa',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 18, paddingVertical: 18,
paddingHorizontal: 12, paddingHorizontal: 12,
gap: 12, gap: 12,
transform: [{ scale: pressed ? 0.97 : 1 }], }}>
})} <SvgXml xml={svg} width={56} height={56} />
> <View style={{ alignItems: 'center', gap: 2 }}>
<SvgXml xml={svg} width={56} height={56} /> <Text
<View style={{ alignItems: 'center', gap: 2 }}> style={{
<Text fontFamily: 'Nunito_700Bold',
style={{ color: '#0a0a0a',
fontFamily: 'Nunito_700Bold', fontSize: 15,
color: '#0a0a0a', }}
fontSize: 15, >
}} {t(titleKey)}
> </Text>
{t(titleKey)} <Text
</Text> numberOfLines={2}
<Text style={{
numberOfLines={2} textAlign: 'center',
style={{ fontFamily: 'Nunito_400Regular',
textAlign: 'center', color: '#737373',
fontFamily: 'Nunito_400Regular', fontSize: 11,
color: '#737373', lineHeight: 14,
fontSize: 11, minHeight: 28,
lineHeight: 14, paddingHorizontal: 4,
minHeight: 28, }}
paddingHorizontal: 4, >
}} {t(descKey)}
> </Text>
{t(descKey)} <View style={{ marginTop: 4 }}>
</Text> <GameRatingStars avg={avgStars} count={count} />
<View style={{ marginTop: 4 }}> </View>
<GameRatingStars avg={avgStars} count={count} />
</View> </View>
</View> </View>
</Pressable> </Pressable>

View File

@ -171,9 +171,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
void item.onSelect(); void item.onSelect();
}} }}
android_ripple={{ color: '#e5e7eb' }} android_ripple={{ color: '#e5e7eb' }}
style={({ pressed }) => ({ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
})}
> >
<View <View
style={{ style={{
@ -208,9 +206,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
<Pressable <Pressable
onPress={handleLogout} onPress={handleLogout}
android_ripple={{ color: '#e5e7eb' }} android_ripple={{ color: '#e5e7eb' }}
style={({ pressed }) => ({ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
})}
> >
<View <View
style={{ style={{

View File

@ -318,6 +318,10 @@ function ProviderGrid({
onPress={() => onSelect(p)} onPress={() => onSelect(p)}
style={({ pressed }) => ({ style={({ pressed }) => ({
width: '47%', width: '47%',
opacity: pressed ? 0.7 : 1,
})}
>
<View style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
@ -326,9 +330,7 @@ function ProviderGrid({
borderColor: '#e5e5e5', borderColor: '#e5e5e5',
borderRadius: 14, borderRadius: 14,
padding: 14, padding: 14,
opacity: pressed ? 0.7 : 1, }}>
})}
>
<View <View
style={{ style={{
width: 36, width: 36,
@ -350,6 +352,7 @@ function ProviderGrid({
</Text> </Text>
</View> </View>
<Ionicons name="chevron-forward" size={14} color="#d4d4d4" /> <Ionicons name="chevron-forward" size={14} color="#d4d4d4" />
</View>
</Pressable> </Pressable>
))} ))}
</View> </View>
@ -583,22 +586,25 @@ function FormView({
onPress={onConnect} onPress={onConnect}
disabled={!canConnect} disabled={!canConnect}
style={({ pressed }) => ({ style={({ pressed }) => ({
backgroundColor: canConnect ? '#007AFF' : '#d4d4d4',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
opacity: pressed ? 0.85 : 1, opacity: pressed ? 0.85 : 1,
marginTop: 4, marginTop: 4,
marginBottom: insets.bottom > 0 ? 8 : 12, marginBottom: insets.bottom > 0 ? 8 : 12,
})} })}
> >
{connecting ? ( <View style={{
<ActivityIndicator color="#fff" /> backgroundColor: canConnect ? '#007AFF' : '#d4d4d4',
) : ( borderRadius: 14,
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}> paddingVertical: 14,
{t('mail.form_connect_btn')} alignItems: 'center',
</Text> }}>
)} {connecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.form_connect_btn')}
</Text>
)}
</View>
</Pressable> </Pressable>
</ScrollView> </ScrollView>
); );

View File

@ -224,20 +224,23 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
disabled={!password.trim() || connecting} disabled={!password.trim() || connecting}
style={({ pressed }) => ({ style={({ pressed }) => ({
marginTop: 4, marginTop: 4,
opacity: pressed ? 0.85 : 1,
})}
>
<View style={{
paddingVertical: 14, paddingVertical: 14,
borderRadius: 12, borderRadius: 12,
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF', backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
alignItems: 'center', alignItems: 'center',
opacity: pressed ? 0.85 : 1, }}>
})} {connecting ? (
> <ActivityIndicator color="#fff" />
{connecting ? ( ) : (
<ActivityIndicator color="#fff" /> <Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
) : ( {t('mail.edit_account_save')}
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}> </Text>
{t('mail.edit_account_save')} )}
</Text> </View>
)}
</Pressable> </Pressable>
<View style={{ height: insets.bottom }} /> <View style={{ height: insets.bottom }} />

View File

@ -82,18 +82,21 @@ export function MailEmptyState({ onConnectPress }: Props) {
<Pressable <Pressable
onPress={onConnectPress} onPress={onConnectPress}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.85 : 1,
alignSelf: 'stretch',
})}
>
<View style={{
backgroundColor: '#007AFF', backgroundColor: '#007AFF',
borderRadius: 14, borderRadius: 14,
paddingVertical: 14, paddingVertical: 14,
paddingHorizontal: 28, paddingHorizontal: 28,
alignSelf: 'stretch',
alignItems: 'center', alignItems: 'center',
opacity: pressed ? 0.85 : 1, }}>
})} <Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
> {t('mail.empty_state_cta')}
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}> </Text>
{t('mail.empty_state_cta')} </View>
</Text>
</Pressable> </Pressable>
</View> </View>
); );

View File

@ -29,36 +29,34 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
<View style={{ marginHorizontal: 16, marginTop: 12 }}> <View style={{ marginHorizontal: 16, marginTop: 12 }}>
<Pressable <Pressable
onPress={toggle} onPress={toggle}
style={({ pressed }) => ({ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 12,
opacity: pressed ? 0.7 : 1,
})}
> >
<Ionicons <View
name="shield-checkmark-outline" style={{
size={16} flexDirection: 'row',
color={colors.textMuted} alignItems: 'center',
style={{ marginRight: 8 }} justifyContent: 'space-between',
/> backgroundColor: '#ffffff',
<Text style={{ flex: 1, fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}> borderWidth: 1,
Approved Domains{' '} borderColor: '#e5e5e5',
<Text style={{ color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}> borderRadius: 14,
({domains.length}) padding: 16,
</Text> }}
</Text> >
<Ionicons <View style={{ flex: 1 }}>
name={expanded ? 'chevron-up' : 'chevron-down'} <Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
size={16} Approved Domains{' '}
color={colors.textMuted} <Text style={{ color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
/> ({domains.length})
</Text>
</Text>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.textMuted}
/>
</View>
</Pressable> </Pressable>
{expanded ? ( {expanded ? (

View File

@ -1,19 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
View, View,
Text, Text,
Pressable, Pressable,
TextInput, Switch,
Modal,
LayoutAnimation, LayoutAnimation,
Platform, Platform,
UIManager, UIManager,
ScrollView,
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { getCitiesForBundesland } from '../../lib/germanCities';
import { WheelPickerModal } from '../WheelPickerModal';
import { colors } from '../../lib/theme'; import { colors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan'; 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) { if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true); UIManager.setLayoutAnimationEnabledExperimental(true);
} }
@ -22,7 +28,10 @@ export type Demographics = {
birthYear: number | null; birthYear: number | null;
gender: string | null; gender: string | null;
maritalStatus: string | null; maritalStatus: string | null;
profession: string | null; employmentStatus: string | null;
shiftWork: boolean | null;
industry: string | null;
jobTenure: string | null;
bundesland: string | null; bundesland: string | null;
city: string | null; city: string | null;
}; };
@ -36,12 +45,10 @@ type Props = {
onRevokeConsent?: () => void; onRevokeConsent?: () => void;
}; };
// Select-Optionen — Display-Label DE, value für DB-Persistenz
const GENDER_OPTIONS: Array<{ label: string; value: string }> = [ const GENDER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'männlich', value: 'male' }, { label: 'männlich', value: 'male' },
{ label: 'weiblich', value: 'female' }, { label: 'weiblich', value: 'female' },
{ label: 'divers', value: 'diverse' }, { label: 'divers', value: 'diverse' },
{ label: 'keine Angabe', value: 'none' },
]; ];
const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [ const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
@ -53,7 +60,38 @@ const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'keine Angabe', value: 'none' }, { label: 'keine Angabe', value: 'none' },
]; ];
// ISO-3166-2:DE — value=ISO, label=DE-Display 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' },
];
const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [ const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'Baden-Württemberg', value: 'BW' }, { label: 'Baden-Württemberg', value: 'BW' },
{ label: 'Bayern', value: 'BY' }, { label: 'Bayern', value: 'BY' },
@ -73,39 +111,38 @@ const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'Thüringen', value: 'TH' }, { label: 'Thüringen', value: 'TH' },
]; ];
const FIELD_WHY: Record<keyof Demographics, string> = { const STATUS_WITH_SHIFT: Array<string> = ['employed', 'self_employed'];
birthYear: const STATUS_WITH_INDUSTRY: Array<string> = ['employed', 'self_employed', 'in_training'];
'Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe.', const STATUS_WITH_TENURE: Array<string> = ['employed', 'self_employed'];
gender: 'Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel.',
profession: function relevantFieldCount(d: Demographics): { filled: number; total: number } {
'Schichtarbeit, Banking-Stress, Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext.', const base = [d.birthYear !== null, !!d.gender, !!d.maritalStatus, !!d.employmentStatus, !!d.bundesland, !!d.city];
maritalStatus: let filled = base.filter(Boolean).length;
'Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir.', let total = base.length;
bundesland: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
city: 'Lokale Beratungsstellen + anonyme DiGA-Studien.', 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) { function lookupLabel(options: Array<{ label: string; value: string }>, v: string | null) {
if (!v) return null; if (!v) return null;
return options.find((o) => o.value === v)?.label ?? v; return options.find((o) => o.value === v)?.label ?? v;
} }
function isComplete(d: Demographics) { function mockPersist(_next: 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({ export function DemographicsAccordion({
demographics, demographics,
@ -127,10 +164,16 @@ export function DemographicsAccordion({
const expanded = expandedLocal; const expanded = expandedLocal;
const [local, setLocal] = useState<Demographics>(demographics); const [local, setLocal] = useState<Demographics>(demographics);
// Select-Sheet-State // Generic wheel-picker state — alle Demographics-Auswahlfelder rendern via Wheel
const [pickerField, setPickerField] = useState<keyof Demographics | null>(null); // (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);
// Debounce-Save Ref
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@ -158,57 +201,112 @@ export function DemographicsAccordion({
setLocal(next); setLocal(next);
} }
const completed = isComplete(local); const { filled, total } = relevantFieldCount(local);
const showProTrialBanner = plan === 'free' && completed; 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 ( return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}> <View style={{ marginHorizontal: 16, marginTop: 24 }}>
{/* Privacy-Header */}
<Pressable <Pressable
onPress={toggle} onPress={toggle}
style={({ pressed }) => ({ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
opacity: pressed ? 0.7 : 1,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 16,
})}
> >
<View <View
style={{ style={{
flexDirection: 'row', backgroundColor: '#ffffff',
alignItems: 'center', borderWidth: 1,
justifyContent: 'space-between', borderColor: '#e5e5e5',
borderRadius: 14,
padding: 16,
}} }}
> >
<View style={{ flex: 1 }}> <View
<Text style={{
style={{ flexDirection: 'row',
fontSize: 11, alignItems: 'center',
color: colors.textMuted, justifyContent: 'space-between',
fontFamily: 'Nunito_700Bold', }}
letterSpacing: 0.8, >
}} <View style={{ flex: 1 }}>
> <Text
ANONYMER BEITRAG ZUR FORSCHUNG style={{
</Text> fontSize: 11,
<Text color: colors.textMuted,
style={{ fontFamily: 'Nunito_700Bold',
marginTop: 6, letterSpacing: 0.8,
fontSize: 13, }}
color: colors.text, >
fontFamily: 'Nunito_600SemiBold', ANONYMER BEITRAG ZUR FORSCHUNG
}} </Text>
> <Text
Optional. Niemals mit Name oder Email verknüpft. Jederzeit löschbar. style={{
</Text> 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> </View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'} {completed ? (
size={18} <View style={{ marginTop: 10, flexDirection: 'row', alignItems: 'center', gap: 6 }}>
color={colors.textMuted} <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: '#e5e5e5',
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> </View>
</Pressable> </Pressable>
@ -223,10 +321,7 @@ export function DemographicsAccordion({
paddingVertical: 4, paddingVertical: 4,
}} }}
> >
{/* Pro-Trial-Reward-Banner nur free + (idealerweise) nicht-vollständig. {showProTrialBanner ? (
Wir zeigen ihn aber auch im "completed"-State als sanfte Bestätigung,
tatsächliche Trial-Vergabe ist Backend-Sache (Phase C). */}
{plan === 'free' ? (
<View <View
style={{ style={{
marginHorizontal: 8, marginHorizontal: 8,
@ -250,7 +345,7 @@ export function DemographicsAccordion({
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
}} }}
> >
{showProTrialBanner {completed
? 'Du bekommst 1 Woche Pro geschenkt' ? 'Du bekommst 1 Woche Pro geschenkt'
: 'Vervollständige dein Profil — 1 Woche Pro geschenkt'} : 'Vervollständige dein Profil — 1 Woche Pro geschenkt'}
</Text> </Text>
@ -270,153 +365,281 @@ export function DemographicsAccordion({
</View> </View>
) : null} ) : null}
{/* Birth Year — Number-Input */}
<FieldRow <FieldRow
label="Geburtsjahr" label="Geburtsjahr"
why={FIELD_WHY.birthYear} why="Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe."
filled={local.birthYear !== null}
> >
<TextInput <SelectButton
value={local.birthYear !== null ? String(local.birthYear) : ''} value={local.birthYear !== null ? String(local.birthYear) : null}
onChangeText={(raw) => { onPress={() =>
const cleaned = raw.replace(/[^0-9]/g, '').slice(0, 4); setWheelConfig({
if (cleaned === '') { title: 'Geburtsjahr',
persist({ ...local, birthYear: null }); options: BIRTH_YEAR_OPTIONS,
return; value: local.birthYear,
} onSelect: (v) => flushSave({ ...local, birthYear: v as number }),
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> </FieldRow>
{/* Gender — Select */} <FieldRow
<FieldRow label="Geschlecht" why={FIELD_WHY.gender}> label="Geschlecht"
why="Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel."
filled={!!local.gender}
>
<SelectButton <SelectButton
value={lookupLabel(GENDER_OPTIONS, local.gender)} value={lookupLabel(GENDER_OPTIONS, local.gender)}
onPress={() => setPickerField('gender')} onPress={() =>
setWheelConfig({
title: 'Geschlecht',
options: GENDER_OPTIONS,
value: local.gender,
onSelect: (v) => flushSave({ ...local, gender: v as string }),
})
}
/> />
</FieldRow> </FieldRow>
{/* Profession — TextInput */} <FieldRow
<FieldRow label="Beruf" why={FIELD_WHY.profession}> label="Familienstand"
<TextInput why="Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir."
value={local.profession ?? ''} filled={!!local.maritalStatus}
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 <SelectButton
value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)} value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)}
onPress={() => setPickerField('maritalStatus')} onPress={() =>
setWheelConfig({
title: 'Familienstand',
options: MARITAL_OPTIONS,
value: local.maritalStatus,
onSelect: (v) => flushSave({ ...local, maritalStatus: v as string }),
})
}
/> />
</FieldRow> </FieldRow>
{/* Bundesland — Select */} {/* Beruf-Section */}
<FieldRow label="Bundesland" why={FIELD_WHY.bundesland}> <View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 4 }}>
<SelectButton <Text
value={lookupLabel(BUNDESLAND_OPTIONS, local.bundesland)} style={{
onPress={() => setPickerField('bundesland')} fontSize: 11,
/> color: colors.textMuted,
</FieldRow> fontFamily: 'Nunito_700Bold',
letterSpacing: 0.6,
{/* 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" BERUF
placeholderTextColor={colors.textMuted} </Text>
style={inputStyle} </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> </FieldRow>
{/* Revoke Consent */} {showShiftWork ? (
<Pressable <FieldRow
onPress={onRevokeConsent} label="Schichtarbeit"
style={({ pressed }) => ({ why=""
opacity: pressed ? 0.7 : 1, filled={local.shiftWork !== null}
marginTop: 4, 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: '#e5e5e5', 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, paddingHorizontal: 14,
paddingVertical: 12, paddingTop: 12,
paddingBottom: 4,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)', borderTopColor: 'rgba(0,0,0,0.06)',
})} marginTop: 4,
}}
> >
<Text <Text
style={{ style={{
fontSize: 12, fontSize: 11,
color: colors.error, color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold', fontFamily: 'Nunito_700Bold',
textAlign: 'center', letterSpacing: 0.6,
}} }}
> >
Einwilligung widerrufen WOHNORT
</Text> </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}
<Pressable
onPress={onRevokeConsent}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<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>
</Pressable> </Pressable>
</View> </View>
) : null} ) : null}
<SelectSheet <WheelPickerModal
visible={pickerField === 'gender'} visible={wheelConfig !== null}
title="Geschlecht" title={wheelConfig?.title ?? ''}
options={GENDER_OPTIONS} options={(wheelConfig?.options ?? []) as Array<{ value: string | number; label: string }>}
selectedValue={local.gender} value={wheelConfig?.value ?? null}
onClose={() => setPickerField(null)} onSelect={(v) => wheelConfig?.onSelect(v)}
onSelect={(v) => { onClose={() => setWheelConfig(null)}
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> </View>
); );
} }
@ -439,17 +662,23 @@ function FieldRow({
label, label,
why, why,
isLast, isLast,
indent,
hideWhy,
filled,
children, children,
}: { }: {
label: string; label: string;
why: string; why: string;
isLast?: boolean; isLast?: boolean;
indent?: boolean;
hideWhy?: boolean;
filled: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<View <View
style={{ style={{
paddingHorizontal: 14, paddingHorizontal: indent ? 20 : 14,
paddingVertical: 12, paddingVertical: 12,
borderBottomWidth: isLast ? 0 : 1, borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: 'rgba(0,0,0,0.06)', borderBottomColor: 'rgba(0,0,0,0.06)',
@ -463,29 +692,38 @@ function FieldRow({
gap: 12, gap: 12,
}} }}
> >
<Text <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 }}>
style={{ <Ionicons
flex: 1, name={filled ? 'checkmark-circle' : 'warning-outline'}
fontSize: 13, size={14}
color: colors.text, color={filled ? '#16a34a' : '#f59e0b'}
fontFamily: 'Nunito_600SemiBold', />
}} <Text
> style={{
{label} fontSize: 13,
</Text> color: colors.text,
fontFamily: 'Nunito_600SemiBold',
flex: 1,
}}
>
{label}
</Text>
</View>
{children} {children}
</View> </View>
<Text {!hideWhy && why ? (
style={{ <Text
marginTop: 6, style={{
fontSize: 11, marginTop: 6,
color: colors.textMuted, fontSize: 11,
fontFamily: 'Nunito_400Regular', color: colors.textMuted,
lineHeight: 15, fontFamily: 'Nunito_400Regular',
}} lineHeight: 15,
> }}
{why} >
</Text> {why}
</Text>
) : null}
</View> </View>
); );
} }
@ -494,140 +732,33 @@ function SelectButton({ value, onPress }: { value: string | null; onPress: () =>
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
style={({ pressed }) => ({ style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
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 <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
style={{ {/* Value als Chip */}
fontSize: 14, <View
color: value ? colors.text : colors.textMuted, style={{
fontFamily: value ? 'Nunito_600SemiBold' : 'Nunito_400Regular', paddingVertical: 6,
textAlign: 'right', paddingHorizontal: 12,
}} backgroundColor: '#f4f4f5',
> borderRadius: 999,
{value ?? 'auswählen'} borderWidth: 1,
</Text> borderColor: '#e4e4e7',
<Ionicons name="chevron-down" size={14} color={colors.textMuted} /> }}
>
<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>
</Pressable> </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>
);
}

View File

@ -63,31 +63,36 @@ export function DigaMissionBanner({ onDismiss, onContribute }: Props) {
onPress={onContribute} onPress={onContribute}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
})}
>
<View style={{
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 7, paddingVertical: 7,
backgroundColor: '#854d0e', backgroundColor: '#854d0e',
borderRadius: 8, borderRadius: 8,
})} }}>
> <Text
<Text style={{
style={{ fontSize: 12,
fontSize: 12, color: '#ffffff',
color: '#ffffff', fontFamily: 'Nunito_600SemiBold',
fontFamily: 'Nunito_600SemiBold', }}
}} >
> Beitragen
Beitragen </Text>
</Text> </View>
</Pressable> </Pressable>
<Pressable <Pressable
onPress={onDismiss} onPress={onDismiss}
style={({ pressed }) => ({ style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
})}
>
<View style={{
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 7, paddingVertical: 7,
borderRadius: 8, borderRadius: 8,
})} }}>
>
<Text <Text
style={{ style={{
fontSize: 12, fontSize: 12,
@ -97,6 +102,7 @@ export function DigaMissionBanner({ onDismiss, onContribute }: Props) {
> >
Später Später
</Text> </Text>
</View>
</Pressable> </Pressable>
</View> </View>
</View> </View>

View File

@ -1,10 +1,30 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, Pressable, Image } from 'react-native'; import { View, Text, Pressable, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Svg, { Path } from 'react-native-svg';
import { colors } from '../../lib/theme'; import { colors } from '../../lib/theme';
import { resolveAvatar } from '../../lib/resolveAvatar'; import { resolveAvatar } from '../../lib/resolveAvatar';
import type { Plan } from '../../hooks/useUserPlan'; import type { Plan } from '../../hooks/useUserPlan';
function GoogleIcon() {
return (
<Svg width={14} height={14} viewBox="0 0 24 24">
<Path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
<Path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<Path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<Path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</Svg>
);
}
function AppleIcon() {
return (
<Svg width={14} height={14} viewBox="0 0 24 24" fill="#0a0a0a">
<Path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
</Svg>
);
}
export type AuthProvider = 'apple' | 'google' | 'email'; export type AuthProvider = 'apple' | 'google' | 'email';
type Props = { type Props = {
@ -14,6 +34,7 @@ type Props = {
plan: Plan; plan: Plan;
memberSince: string; memberSince: string;
provider: AuthProvider; provider: AuthProvider;
demographicsComplete?: boolean;
showDemographicsHint?: boolean; showDemographicsHint?: boolean;
onEditAvatar?: () => void; onEditAvatar?: () => void;
onEditNickname?: () => void; onEditNickname?: () => void;
@ -26,10 +47,11 @@ const planLabel: Record<Plan, string> = {
legend: 'Legend', legend: 'Legend',
}; };
const planColors: Record<Plan, { bg: string; text: string; border: string }> = { const planColors: Record<Plan, { bg: string; text: string; border: string; icon?: 'star' | 'sparkles' }> = {
free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' }, free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' },
pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' }, pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' },
legend: { bg: '#fef9c3', text: '#854d0e', border: '#fde68a' }, // Legend: vivid gold, white text, premium-look mit sparkles-icon
legend: { bg: '#f59e0b', text: '#ffffff', border: '#d97706', icon: 'sparkles' },
}; };
export function ProfileHeader({ export function ProfileHeader({
@ -39,6 +61,7 @@ export function ProfileHeader({
plan, plan,
memberSince, memberSince,
provider, provider,
demographicsComplete,
showDemographicsHint, showDemographicsHint,
onEditAvatar, onEditAvatar,
onEditNickname, onEditNickname,
@ -57,8 +80,9 @@ export function ProfileHeader({
: null; : null;
return ( return (
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}> <View style={{ paddingVertical: 24, paddingHorizontal: 20 }}>
{/* Avatar — Pressable; Camera-Badge ist eigene Pressable (vorher nur dekoratives View) */} {/* Avatar — Pressable in alignSelf:center-Wrapper (Pressable+style-fn ignoriert alignSelf manchmal in RN) */}
<View style={{ alignSelf: 'center' }}>
<Pressable <Pressable
onPress={onEditAvatar} onPress={onEditAvatar}
style={({ pressed }) => ({ style={({ pressed }) => ({
@ -92,7 +116,7 @@ export function ProfileHeader({
)} )}
</View> </View>
{/* Camera-Badge — iOS-Photos-Pattern: blauer Kreis, weißes Icon */} {/* Camera-Badge — iOS-Photos-Pattern: schwarzer Kreis, weißes Icon */}
<View <View
pointerEvents="none" pointerEvents="none"
style={{ style={{
@ -112,8 +136,10 @@ export function ProfileHeader({
<Ionicons name="camera" size={14} color="#ffffff" /> <Ionicons name="camera" size={14} color="#ffffff" />
</View> </View>
</Pressable> </Pressable>
</View>
{/* Nickname — ganze Zeile Pressable (iOS-Settings-Pattern), kein hässliches Pencil */} {/* Nickname — ganze Zeile Pressable in alignSelf:center-Wrapper */}
<View style={{ alignSelf: 'center' }}>
<Pressable <Pressable
onPress={onEditNickname} onPress={onEditNickname}
hitSlop={8} hitSlop={8}
@ -134,110 +160,144 @@ export function ProfileHeader({
> >
{nickname} {nickname}
</Text> </Text>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</Pressable> </Pressable>
</View>
{providerPillLabel ? ( {/* Plan-Tier-Badge direkt unter Nickname — Legend mit sparkles-icon */}
<View <View
style={{ style={{
marginTop: 4, alignSelf: 'center',
flexDirection: 'row', marginTop: 8,
alignItems: 'center', flexDirection: 'row',
gap: 6, alignItems: 'center',
paddingHorizontal: 10, gap: 6,
paddingVertical: 4, paddingHorizontal: plan === 'legend' ? 14 : 12,
borderRadius: 999, paddingVertical: plan === 'legend' ? 6 : 4,
backgroundColor: '#f5f5f5', borderRadius: 999,
borderWidth: 1, backgroundColor: planStyle.bg,
borderColor: '#e5e5e5', borderWidth: 1.5,
}} borderColor: planStyle.border,
> }}
<Ionicons >
name={provider === 'apple' ? 'logo-apple' : 'logo-google'} {planStyle.icon ? (
size={11} <Ionicons name={planStyle.icon} size={13} color={planStyle.text} />
color={colors.textMuted} ) : null}
/> <Text
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}> style={{
{providerPillLabel} fontSize: plan === 'legend' ? 13 : 11,
</Text> color: planStyle.text,
</View> fontFamily: 'Nunito_700Bold',
) : ( letterSpacing: plan === 'legend' ? 1.2 : 0.4,
}}
>
{planLabel[plan].toUpperCase()}
</Text>
</View>
<View
style={{
alignSelf: 'center',
marginTop: 8,
flexDirection: demographicsComplete ? 'column' : 'row',
alignItems: 'center',
gap: demographicsComplete ? 4 : 8,
}}
>
{providerPillLabel ? (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: '#f5f5f5',
borderWidth: 1,
borderColor: '#e5e5e5',
}}
>
{provider === 'google' ? <GoogleIcon /> : <AppleIcon />}
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{providerPillLabel}
</Text>
</View>
) : (
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{email}
</Text>
)}
<Text <Text
style={{ style={{
marginTop: 4,
fontSize: 12, fontSize: 12,
color: colors.textMuted, color: colors.textMuted,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
}} }}
> >
{email}
</Text>
)}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12 }}>
<View
style={{
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: planStyle.bg,
borderWidth: 1,
borderColor: planStyle.border,
}}
>
<Text
style={{
fontSize: 11,
color: planStyle.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.4,
}}
>
{planLabel[plan].toUpperCase()}
</Text>
</View>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
Mitglied seit {memberSince} Mitglied seit {memberSince}
</Text> </Text>
</View> </View>
{/* Freundlicher Hint statt Progress-Bar — nur sichtbar wenn Demographics unvollständig */} {/* Freundlicher Hint nur sichtbar wenn Demographics unvollständig.
Parent ist KEIN alignItems:center mehr Hint nimmt natürlich volle Breite (default flex-stretch).
KEIN width:'100%' (Konflikt mit alignSelf:stretch in alignItems:center-Kontext war der Bug). */}
{showDemographicsHint ? ( {showDemographicsHint ? (
<Pressable <Pressable
onPress={onDemographicsHintPress} onPress={onDemographicsHintPress}
hitSlop={6} hitSlop={6}
style={({ pressed }) => ({ style={({ pressed }) => ({
alignSelf: 'stretch', alignSelf: 'center',
width: '100%', marginTop: 16,
marginTop: 14,
paddingHorizontal: 14,
paddingVertical: 12,
borderRadius: 12,
backgroundColor: '#f5f8ff',
borderWidth: 1,
borderColor: '#dbe5ff',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
opacity: pressed ? 0.7 : 1, opacity: pressed ? 0.7 : 1,
})} })}
> >
<Ionicons name="heart-outline" size={16} color={colors.brandOrange} style={{ flexShrink: 0 }} /> <View
<Text
numberOfLines={2}
style={{ style={{
flex: 1, backgroundColor: '#fcd34d',
minWidth: 0, borderWidth: 1.5,
flexShrink: 1, borderColor: '#d97706',
fontSize: 12, borderRadius: 14,
color: colors.text, paddingHorizontal: 16,
fontFamily: 'Nunito_600SemiBold', paddingVertical: 12,
lineHeight: 17, flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
}} }}
> >
Hilf uns rebreak besser zu machen fülle deine anonymen Daten aus. <Ionicons name="warning-outline" size={20} color="#92400e" />
</Text> <View>
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} style={{ flexShrink: 0 }} /> <Text
style={{
fontSize: 13,
color: '#92400e',
fontFamily: 'Nunito_700Bold',
lineHeight: 18,
textAlign: 'center',
}}
>
Hilf uns rebreak besser zu machen
</Text>
<Text
style={{
marginTop: 2,
fontSize: 12,
color: '#92400e',
fontFamily: 'Nunito_600SemiBold',
lineHeight: 16,
textAlign: 'center',
}}
>
Fülle deine anonymen Daten aus.
</Text>
</View>
</View>
</Pressable> </Pressable>
) : null} ) : null}
</View> </View>

View File

@ -1,5 +1,4 @@
import { View, Text, Pressable } from 'react-native'; import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme'; import { colors } from '../../lib/theme';
type Props = { type Props = {
@ -14,70 +13,59 @@ type Props = {
type CardProps = { type CardProps = {
value: string; value: string;
label: string; label: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
onPress?: () => void; onPress?: () => void;
}; };
function StatCard({ value, label, icon, onPress }: CardProps) { function StatPill({ value, label, onPress }: CardProps) {
return ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
style={({ pressed }) => ({ style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
flex: 1,
opacity: pressed ? 0.6 : 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
paddingHorizontal: 8,
})}
> >
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}> <View
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 999,
paddingVertical: 10,
paddingHorizontal: 18,
alignItems: 'center',
minWidth: 88,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
}}
>
<Text <Text
style={{ style={{
fontSize: 30, fontSize: 18,
color: colors.text, color: colors.text,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
letterSpacing: -0.5, letterSpacing: -0.2,
}} }}
> >
{value} {value}
</Text> </Text>
{icon ? ( <Text
<Ionicons name={icon} size={16} color={colors.textMuted} style={{ marginTop: 4 }} /> style={{
) : null} marginTop: 1,
fontSize: 10,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
numberOfLines={1}
>
{label}
</Text>
</View> </View>
<Text
style={{
marginTop: 4,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
>
{label}
</Text>
</Pressable> </Pressable>
); );
} }
function Divider() {
return (
<View
style={{
width: 1,
backgroundColor: 'rgba(0,0,0,0.08)',
marginVertical: 14,
}}
/>
);
}
/**
* Community-Stats: 3 prominente Cards in einer zentrierten Reihe.
* - Posts / Follower / Approved Domains
* - Approved Domains: PLAIN INTEGER (kein Cap), Trophy-Icon als Community-Beitrag-Hint
*/
export function StatsBar({ export function StatsBar({
postsCount, postsCount,
followersCount, followersCount,
@ -87,27 +75,21 @@ export function StatsBar({
onApprovedDomainsPress, onApprovedDomainsPress,
}: Props) { }: Props) {
return ( return (
<View style={{ paddingHorizontal: 16, alignItems: 'center' }}> <View style={{ paddingHorizontal: 16 }}>
<View <View
style={{ style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'stretch', alignItems: 'center',
backgroundColor: '#fafafa', justifyContent: 'center',
borderRadius: 18, gap: 10,
borderWidth: 1, flexWrap: 'wrap',
borderColor: '#ececec',
width: '100%',
maxWidth: 420,
}} }}
> >
<StatCard value={String(postsCount)} label="Posts" onPress={onPostsPress} /> <StatPill value={String(postsCount)} label="Posts" onPress={onPostsPress} />
<Divider /> <StatPill value={String(followersCount)} label="Follower" onPress={onFollowersPress} />
<StatCard value={String(followersCount)} label="Follower" onPress={onFollowersPress} /> <StatPill
<Divider />
<StatCard
value={String(approvedDomainsCount)} value={String(approvedDomainsCount)}
label="Approved Domains" label="Domains"
icon="trophy-outline"
onPress={onApprovedDomainsPress} onPress={onApprovedDomainsPress}
/> />
</View> </View>

View File

@ -39,16 +39,19 @@ export function GamePickerGrid({ onSelect }: { onSelect: (game: GameType) => voi
onPress={() => onSelect(game.id)} onPress={() => onSelect(game.id)}
style={({ pressed }) => ({ style={({ pressed }) => ({
width: '47%', width: '47%',
opacity: pressed ? 0.75 : 1,
})}
>
<View style={{
aspectRatio: 1, aspectRatio: 1,
borderRadius: 16, borderRadius: 16,
borderWidth: 1, borderWidth: 1,
borderColor: '#e5e7eb', borderColor: '#e5e7eb',
backgroundColor: pressed ? '#f0f9ff' : '#f9fafb', backgroundColor: '#f9fafb',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 12, padding: 12,
})} }}>
>
<SvgXml xml={game.svg} width={56} height={56} /> <SvgXml xml={game.svg} width={56} height={56} />
<Text style={{ marginTop: 10, fontFamily: 'Nunito_700Bold', color: '#111827', fontSize: 14 }}> <Text style={{ marginTop: 10, fontFamily: 'Nunito_700Bold', color: '#111827', fontSize: 14 }}>
{t(game.titleKey)} {t(game.titleKey)}
@ -67,6 +70,7 @@ export function GamePickerGrid({ onSelect }: { onSelect: (game: GameType) => voi
> >
{t(game.descKey)} {t(game.descKey)}
</Text> </Text>
</View>
</Pressable> </Pressable>
))} ))}
</View> </View>

View File

@ -0,0 +1,89 @@
/**
* Top-Städte pro Bundesland (anonymized DiGA-Demographics).
* Curated by population covers ~85% of population.
* Bundesland-Code = ISO 3166-2:DE (BW, BY, BE, ...).
*
* Falls User-Stadt nicht in Liste: Fallback "Andere" free-text TextInput
* (separate UX-Path, in Phase C wenn relevant).
*/
export const GERMAN_CITIES_BY_BUNDESLAND: Record<string, string[]> = {
BW: [
'Stuttgart', 'Mannheim', 'Karlsruhe', 'Freiburg', 'Heidelberg', 'Heilbronn',
'Ulm', 'Pforzheim', 'Reutlingen', 'Esslingen', 'Tübingen', 'Ludwigsburg',
'Konstanz', 'Aalen', 'Sindelfingen', 'Villingen-Schwenningen', 'Friedrichshafen',
'Offenburg', 'Schwäbisch Gmünd', 'Göppingen', 'Rastatt', 'Baden-Baden',
'Ravensburg', 'Tuttlingen', 'Lörrach', 'Bietigheim-Bissingen',
],
BY: [
'München', 'Nürnberg', 'Augsburg', 'Regensburg', 'Würzburg', 'Ingolstadt',
'Fürth', 'Erlangen', 'Bayreuth', 'Bamberg', 'Aschaffenburg', 'Landshut',
'Kempten', 'Rosenheim', 'Neu-Ulm', 'Schweinfurt', 'Passau', 'Freising',
'Straubing', 'Dachau', 'Memmingen', 'Hof', 'Coburg', 'Ansbach',
'Erding', 'Weiden', 'Kaufbeuren', 'Garmisch-Partenkirchen',
],
BE: ['Berlin'],
BB: [
'Potsdam', 'Cottbus', 'Brandenburg an der Havel', 'Frankfurt (Oder)',
'Oranienburg', 'Eberswalde', 'Falkensee', 'Bernau', 'Königs Wusterhausen',
'Hennigsdorf', 'Werder', 'Strausberg', 'Fürstenwalde', 'Schwedt', 'Senftenberg',
],
HB: ['Bremen', 'Bremerhaven'],
HH: ['Hamburg'],
HE: [
'Frankfurt am Main', 'Wiesbaden', 'Kassel', 'Darmstadt', 'Offenbach',
'Hanau', 'Gießen', 'Marburg', 'Fulda', 'Rüsselsheim', 'Wetzlar', 'Bad Homburg',
'Oberursel', 'Rodgau', 'Dreieich', 'Limburg', 'Bensheim', 'Neu-Isenburg',
'Maintal', 'Langen', 'Hofheim', 'Bad Vilbel',
],
MV: [
'Rostock', 'Schwerin', 'Neubrandenburg', 'Stralsund', 'Greifswald', 'Wismar',
'Güstrow', 'Waren', 'Anklam', 'Bergen auf Rügen', 'Parchim', 'Neustrelitz',
],
NI: [
'Hannover', 'Braunschweig', 'Oldenburg', 'Osnabrück', 'Wolfsburg', 'Göttingen',
'Hildesheim', 'Salzgitter', 'Delmenhorst', 'Lüneburg', 'Wilhelmshaven',
'Celle', 'Hameln', 'Lingen', 'Cuxhaven', 'Emden', 'Goslar', 'Stade',
'Nordhorn', 'Peine', 'Melle', 'Garbsen', 'Langenhagen',
],
NW: [
'Köln', 'Düsseldorf', 'Dortmund', 'Essen', 'Duisburg', 'Bochum', 'Wuppertal',
'Bielefeld', 'Bonn', 'Münster', 'Mönchengladbach', 'Gelsenkirchen', 'Aachen',
'Krefeld', 'Oberhausen', 'Hagen', 'Hamm', 'Mülheim', 'Leverkusen', 'Solingen',
'Herne', 'Neuss', 'Paderborn', 'Bottrop', 'Recklinghausen', 'Bergisch Gladbach',
'Remscheid', 'Moers', 'Siegen', 'Witten', 'Iserlohn', 'Gütersloh',
'Marl', 'Lünen', 'Velbert', 'Minden', 'Dorsten', 'Detmold', 'Castrop-Rauxel',
'Arnsberg', 'Lüdenscheid', 'Bocholt', 'Dinslaken', 'Lippstadt',
],
RP: [
'Mainz', 'Ludwigshafen', 'Koblenz', 'Trier', 'Kaiserslautern', 'Worms',
'Neuwied', 'Neustadt', 'Speyer', 'Frankenthal', 'Bad Kreuznach', 'Pirmasens',
'Idar-Oberstein', 'Zweibrücken', 'Andernach', 'Ingelheim', 'Mayen', 'Landau',
],
SL: ['Saarbrücken', 'Neunkirchen', 'Homburg', 'Völklingen', 'Sankt Ingbert', 'Saarlouis', 'Merzig', 'Dillingen'],
SN: [
'Leipzig', 'Dresden', 'Chemnitz', 'Zwickau', 'Plauen', 'Görlitz', 'Freiberg',
'Bautzen', 'Pirna', 'Riesa', 'Hoyerswerda', 'Meißen', 'Radebeul', 'Freital',
'Glauchau', 'Annaberg-Buchholz', 'Markkleeberg', 'Limbach-Oberfrohna',
],
ST: [
'Magdeburg', 'Halle', 'Dessau-Roßlau', 'Wittenberg', 'Stendal', 'Halberstadt',
'Bernburg', 'Naumburg', 'Weißenfels', 'Aschersleben', 'Merseburg', 'Schönebeck',
'Bitterfeld-Wolfen', 'Sangerhausen', 'Köthen', 'Zeitz',
],
SH: [
'Kiel', 'Lübeck', 'Flensburg', 'Neumünster', 'Norderstedt', 'Elmshorn',
'Pinneberg', 'Itzehoe', 'Wedel', 'Ahrensburg', 'Geesthacht', 'Bad Oldesloe',
'Rendsburg', 'Reinbek', 'Heide', 'Husum',
],
TH: [
'Erfurt', 'Jena', 'Gera', 'Weimar', 'Nordhausen', 'Eisenach', 'Gotha',
'Suhl', 'Mühlhausen', 'Altenburg', 'Sondershausen', 'Bad Salzungen',
'Sonneberg', 'Saalfeld', 'Greiz', 'Apolda', 'Arnstadt', 'Pößneck',
],
};
export function getCitiesForBundesland(bundeslandCode: string | null | undefined): string[] {
if (!bundeslandCode) return [];
return GERMAN_CITIES_BY_BUNDESLAND[bundeslandCode] ?? [];
}

View File

@ -0,0 +1,48 @@
/**
* useNativeActionSheet Cross-Platform Action-Sheet mit garantiert nativem iOS-Bottom-Sheet.
*
* Problem: `@expo/react-native-action-sheet`'s Auto-Detection bricht in pnpm-Monorepo wegen
* `unstable_enablePackageExports: true` in metro.config Metro resolved auf JS-Fallback
* (centered pills) statt iOS-native bottom-sheet.
*
* Fix: Platform.OS === 'ios' ActionSheetIOS direkt aus react-native core (immer native).
* Android @expo/react-native-action-sheet's Material-Style Custom-Sheet.
*/
import { Platform, ActionSheetIOS } from 'react-native';
import { useActionSheet as usePackageActionSheet } from '@expo/react-native-action-sheet';
import type { ActionSheetIOSOptions } from 'react-native';
type Options = {
title?: string;
message?: string;
options: string[];
cancelButtonIndex?: number;
destructiveButtonIndex?: number;
tintColor?: string;
};
type Callback = (idx: number | undefined) => void;
export function useNativeActionSheet(): {
showActionSheetWithOptions: (options: Options, callback: Callback) => void;
} {
const { showActionSheetWithOptions: showPackageSheet } = usePackageActionSheet();
return {
showActionSheetWithOptions: (options, callback) => {
if (Platform.OS === 'ios') {
const iosOptions: ActionSheetIOSOptions = {
options: options.options,
cancelButtonIndex: options.cancelButtonIndex,
destructiveButtonIndex: options.destructiveButtonIndex,
title: options.title,
message: options.message,
tintColor: options.tintColor,
};
ActionSheetIOS.showActionSheetWithOptions(iosOptions, callback);
} else {
showPackageSheet(options, callback);
}
},
};
}

View File

@ -645,5 +645,45 @@
"label_one": "Tag", "label_one": "Tag",
"label_other": "Tage", "label_other": "Tage",
"label_suffix": "clean" "label_suffix": "clean"
},
"demographics": {
"employment_status_employed": "angestellt",
"employment_status_self_employed": "selbständig",
"employment_status_in_training": "in Ausbildung / Studium",
"employment_status_unemployed": "arbeitslos / arbeitssuchend",
"employment_status_retired": "pensioniert / im Ruhestand",
"employment_status_homemaking": "Hausarbeit / Care-Arbeit",
"employment_status_other": "andere",
"industry_it_software": "IT / Software",
"industry_healthcare": "Pflege / Medizin",
"industry_education": "Bildung / Lehre",
"industry_hospitality": "Gastronomie / Hotellerie",
"industry_construction": "Bau / Handwerk",
"industry_banking_finance": "Banking / Finance",
"industry_sales_marketing": "Verkauf / Marketing",
"industry_public_admin": "Verwaltung / Behörde",
"industry_logistics": "Logistik / Transport",
"industry_creative_media": "Kreativ / Medien",
"industry_other": "andere",
"tenure_less_1y": "weniger als 1 Jahr",
"tenure_1_3y": "1-3 Jahre",
"tenure_3_5y": "3-5 Jahre",
"tenure_5_10y": "5-10 Jahre",
"tenure_more_10y": "mehr als 10 Jahre",
"shift_work_yes": "Ja",
"shift_work_no": "Nein",
"shift_work_unknown": "k.A.",
"section_beruf": "BERUF",
"section_wohnort": "WOHNORT",
"field_status": "Status",
"field_shift_work": "Schichtarbeit",
"field_industry": "Branche",
"field_job_tenure": "Im Job seit",
"field_bundesland": "Bundesland",
"field_city": "Stadt",
"picker_employment_status": "Berufs-Status",
"picker_industry": "Branche",
"picker_job_tenure": "Im aktuellen Job seit",
"picker_bundesland": "Bundesland"
} }
} }

View File

@ -645,5 +645,45 @@
"label_one": "day", "label_one": "day",
"label_other": "days", "label_other": "days",
"label_suffix": "clean" "label_suffix": "clean"
},
"demographics": {
"employment_status_employed": "employed",
"employment_status_self_employed": "self-employed",
"employment_status_in_training": "in education / training",
"employment_status_unemployed": "unemployed / job-seeking",
"employment_status_retired": "retired / pensioned",
"employment_status_homemaking": "homemaking / care work",
"employment_status_other": "other",
"industry_it_software": "IT / Software",
"industry_healthcare": "Healthcare / Medicine",
"industry_education": "Education / Teaching",
"industry_hospitality": "Hospitality / Hotels",
"industry_construction": "Construction / Trades",
"industry_banking_finance": "Banking / Finance",
"industry_sales_marketing": "Sales / Marketing",
"industry_public_admin": "Public administration",
"industry_logistics": "Logistics / Transport",
"industry_creative_media": "Creative / Media",
"industry_other": "other",
"tenure_less_1y": "less than 1 year",
"tenure_1_3y": "1-3 years",
"tenure_3_5y": "3-5 years",
"tenure_5_10y": "5-10 years",
"tenure_more_10y": "more than 10 years",
"shift_work_yes": "Yes",
"shift_work_no": "No",
"shift_work_unknown": "n/a",
"section_beruf": "EMPLOYMENT",
"section_wohnort": "LOCATION",
"field_status": "Status",
"field_shift_work": "Shift work",
"field_industry": "Industry",
"field_job_tenure": "In job since",
"field_bundesland": "State",
"field_city": "City",
"picker_employment_status": "Employment status",
"picker_industry": "Industry",
"picker_job_tenure": "Time in current job",
"picker_bundesland": "State"
} }
} }

View File

@ -13,9 +13,13 @@
}, },
"dependencies": { "dependencies": {
"@expo-google-fonts/nunito": "^0.2.3", "@expo-google-fonts/nunito": "^0.2.3",
"@expo/react-native-action-sheet": "^4.1.1",
"@expo/vector-icons": "^14.0.0", "@expo/vector-icons": "^14.0.0",
"@lodev09/react-native-true-sheet": "^3.10.1",
"@react-native-async-storage/async-storage": "^2.1.2", "@react-native-async-storage/async-storage": "^2.1.2",
"@react-native-community/slider": "^5.2.0", "@react-native-community/slider": "^5.2.0",
"@react-native-menu/menu": "^2.0.0",
"@react-native-picker/picker": "2.11.1",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@supabase/supabase-js": "^2.46.0", "@supabase/supabase-js": "^2.46.0",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",

View File

@ -0,0 +1,78 @@
#!/usr/bin/env bash
# build-ios-clean.sh — iOS clean rebuild ohne Festplatten-bloat
#
# Cleart vor jedem Build:
# - Rebreak DerivedData (NICHT alle Projects, nur Rebreak-*)
# - ios/build/ (Xcode workspace build output)
#
# Optional via env:
# CLEAN_PODS=1 → auch ios/Pods nukern + pod install (langsamer, ~3min extra)
# SKIP_BUILD=1 → nur cleanup, kein expo run:ios
#
# Usage:
# ./scripts/build-ios-clean.sh # cleanup + simulator-build
# ./scripts/build-ios-clean.sh --device # cleanup + device-build
# CLEAN_PODS=1 ./scripts/build-ios-clean.sh # full reset
# SKIP_BUILD=1 ./scripts/build-ios-clean.sh # nur disk freigeben
set -euo pipefail
cd "$(dirname "$0")/.."
echo "==================================="
echo " iOS Clean-Build"
echo "==================================="
free_before=$(df -h / | tail -1 | awk '{print $4}')
echo "Free disk (before): $free_before"
echo ""
# 1. Sicherheits-check: läuft gerade ein Xcode/expo-build?
if pgrep -f "xcodebuild|expo run:ios" >/dev/null 2>&1; then
echo "WARNUNG: xcodebuild oder expo run:ios läuft gerade."
echo " Cleanup würde laufenden Build kaputt machen. Abbruch."
exit 1
fi
# 2. Rebreak DerivedData nukern (gezielt, nicht alle Projects)
DD_DIR="$HOME/Library/Developer/Xcode/DerivedData"
REBREAK_DDS=$(find "$DD_DIR" -maxdepth 1 -name "Rebreak-*" -type d 2>/dev/null || true)
if [ -n "$REBREAK_DDS" ]; then
echo "==> Removing Rebreak DerivedData:"
echo "$REBREAK_DDS" | sed 's/^/ /'
echo "$REBREAK_DDS" | xargs rm -rf
else
echo "==> No Rebreak DerivedData found (already clean)"
fi
# 3. ios/build/ (workspace build output, falls vorhanden)
if [ -d "ios/build" ]; then
size=$(du -sh ios/build 2>/dev/null | awk '{print $1}')
echo "==> Removing ios/build/ ($size)"
rm -rf ios/build
fi
# 4. Optional: Pods komplett zurücksetzen
if [ "${CLEAN_PODS:-0}" = "1" ]; then
if [ -d "ios/Pods" ]; then
size=$(du -sh ios/Pods 2>/dev/null | awk '{print $1}')
echo "==> CLEAN_PODS=1 → Removing ios/Pods/ ($size)"
rm -rf ios/Pods ios/Podfile.lock
fi
echo "==> Running pod install"
(cd ios && pod install)
fi
free_after=$(df -h / | tail -1 | awk '{print $4}')
echo ""
echo "Free disk (after): $free_after"
echo ""
# 5. Build (außer SKIP_BUILD=1)
if [ "${SKIP_BUILD:-0}" = "1" ]; then
echo "SKIP_BUILD=1 → cleanup-only, kein expo run:ios"
exit 0
fi
echo "==> Starting expo run:ios $*"
exec npx expo run:ios "$@"

View File

@ -5,7 +5,9 @@
"scripts": { "scripts": {
"dev:backend": "pnpm --filter rebreak-backend dev", "dev:backend": "pnpm --filter rebreak-backend dev",
"dev:native": "pnpm --filter rebreak-native start", "dev:native": "pnpm --filter rebreak-native start",
"dev:admin": "pnpm --filter rebreak-admin dev",
"build:backend": "pnpm --filter rebreak-backend build", "build:backend": "pnpm --filter rebreak-backend build",
"build:admin": "pnpm --filter rebreak-admin build",
"android": "pnpm --filter rebreak-native android", "android": "pnpm --filter rebreak-native android",
"ios": "pnpm --filter rebreak-native ios" "ios": "pnpm --filter rebreak-native ios"
}, },

6374
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff