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:
parent
d7efd627f5
commit
3c52d8869e
217
apps/rebreak-native/.maestro/SETUP.md
Normal file
217
apps/rebreak-native/.maestro/SETUP.md
Normal 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.
|
||||
49
apps/rebreak-native/.maestro/auth/signin.yaml
Normal file
49
apps/rebreak-native/.maestro/auth/signin.yaml
Normal 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"
|
||||
67
apps/rebreak-native/.maestro/community/post.yaml
Normal file
67
apps/rebreak-native/.maestro/community/post.yaml
Normal 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?"
|
||||
69
apps/rebreak-native/.maestro/profile/view-profile.yaml
Normal file
69
apps/rebreak-native/.maestro/profile/view-profile.yaml
Normal 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"
|
||||
76
apps/rebreak-native/.maestro/urge/start-session.yaml
Normal file
76
apps/rebreak-native/.maestro/urge/start-session.yaml
Normal 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?"
|
||||
@ -94,7 +94,6 @@ function NotificationRow({
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
backgroundColor: isUnread ? '#fff7ed' : '#fff',
|
||||
})}
|
||||
>
|
||||
<View
|
||||
@ -105,6 +104,7 @@ function NotificationRow({
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f5f5f5',
|
||||
backgroundColor: isUnread ? '#fff7ed' : '#fff',
|
||||
}}
|
||||
>
|
||||
{/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */}
|
||||
|
||||
@ -5,6 +5,7 @@ import * as Notifications from 'expo-notifications';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import {
|
||||
useFonts,
|
||||
@ -153,9 +154,11 @@ export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActionSheetProvider>
|
||||
<SafeAreaProvider>
|
||||
<RootLayoutInner />
|
||||
</SafeAreaProvider>
|
||||
</ActionSheetProvider>
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
@ -36,14 +36,17 @@ export default function DebugScreen() {
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
||||
Debug
|
||||
|
||||
@ -86,18 +86,21 @@ export default function GamesScreen() {
|
||||
onPress={() => exit()}
|
||||
hitSlop={10}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 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 }}>
|
||||
{t('games.back_to_picker')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
||||
{t(GAME_META.find((g) => g.id === active)!.titleKey)}
|
||||
@ -141,14 +144,17 @@ export default function GamesScreen() {
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
||||
</View>
|
||||
</Pressable>
|
||||
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
||||
{t('games.title')}
|
||||
|
||||
@ -120,10 +120,7 @@ export default function ForeignProfileScreen() {
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
padding: 8,
|
||||
})}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, padding: 8 })}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||
</Pressable>
|
||||
@ -213,14 +210,16 @@ export default function ForeignProfileScreen() {
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
paddingVertical: 11,
|
||||
borderRadius: 12,
|
||||
backgroundColor: isFollowing ? '#f5f5f5' : colors.brandOrange,
|
||||
borderWidth: 1,
|
||||
borderColor: isFollowing ? '#e5e5e5' : colors.brandOrange,
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
@ -230,6 +229,7 @@ export default function ForeignProfileScreen() {
|
||||
>
|
||||
{isFollowing ? 'Folge ich' : 'Folgen'}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
@ -239,14 +239,16 @@ export default function ForeignProfileScreen() {
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
paddingVertical: 11,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
@ -256,6 +258,7 @@ export default function ForeignProfileScreen() {
|
||||
>
|
||||
Nachricht
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -87,20 +87,31 @@ const DUMMY_DEMOGRAPHICS: Demographics = {
|
||||
birthYear: 1989,
|
||||
gender: 'diverse',
|
||||
maritalStatus: null,
|
||||
profession: null,
|
||||
employmentStatus: null,
|
||||
shiftWork: null,
|
||||
industry: null,
|
||||
jobTenure: null,
|
||||
bundesland: 'BY',
|
||||
city: null,
|
||||
};
|
||||
|
||||
function isDemographicsComplete(d: Demographics): boolean {
|
||||
return (
|
||||
const base =
|
||||
d.birthYear !== null &&
|
||||
!!d.gender &&
|
||||
!!d.maritalStatus &&
|
||||
!!d.profession &&
|
||||
!!d.employmentStatus &&
|
||||
!!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() {
|
||||
@ -155,7 +166,7 @@ export default function ProfileScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#ffffff' }}>
|
||||
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
|
||||
<AppHeader showBack title="Profil" />
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
@ -170,17 +181,16 @@ export default function ProfileScreen() {
|
||||
plan={profile.plan}
|
||||
memberSince={profile.memberSince}
|
||||
provider={profile.provider}
|
||||
demographicsComplete={demoComplete}
|
||||
showDemographicsHint={!demoComplete}
|
||||
onDemographicsHintPress={openDemographics}
|
||||
onEditAvatar={() => {
|
||||
// TODO Phase C: AvatarPickerSheet (preset-grid + custom-upload via expo-image-picker)
|
||||
Alert.alert(
|
||||
'Avatar bearbeiten',
|
||||
'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.',
|
||||
);
|
||||
}}
|
||||
onEditNickname={() => {
|
||||
// TODO Phase C: NicknameEditSheet → PATCH /api/auth/me
|
||||
Alert.alert(
|
||||
'Nickname bearbeiten',
|
||||
'Inline-Edit + Save kommt in der nächsten Iteration.',
|
||||
@ -213,8 +223,6 @@ export default function ProfileScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
|
||||
|
||||
{showDigaBanner ? (
|
||||
<DigaMissionBanner
|
||||
onDismiss={() => {
|
||||
@ -261,6 +269,9 @@ export default function ProfileScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ApprovedDomains ans Ende — User-Direktive 2026-05-08 */}
|
||||
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
|
||||
|
||||
<View style={{ height: 24 }} />
|
||||
<Text
|
||||
style={{
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNativeActionSheet } from '../lib/useNativeActionSheet';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '../lib/theme';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
@ -20,123 +19,12 @@ import { useLanguageStore, type AppLanguage } from '../stores/language';
|
||||
import { useUserPlan } from '../hooks/useUserPlan';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
|
||||
// ─── Picker Sheet ──────────────────────────────────────────────────────────
|
||||
// ─── Settings Screen ───────────────────────────────────────────────────────
|
||||
|
||||
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 = {
|
||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||
iconColor: string;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
soon?: boolean;
|
||||
@ -159,10 +47,7 @@ export default function SettingsScreen() {
|
||||
const { mode: themeMode, setMode: setThemeMode } = useThemeStore();
|
||||
const { language, setLanguage } = useLanguageStore();
|
||||
const { plan } = useUserPlan();
|
||||
|
||||
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
||||
const [langPickerOpen, setLangPickerOpen] = useState(false);
|
||||
const [voicePickerOpen, setVoicePickerOpen] = useState(false);
|
||||
const { showActionSheetWithOptions } = useNativeActionSheet();
|
||||
|
||||
// Lyra Voice: hardcoded ElevenLabs voice IDs (expandable by user later)
|
||||
// 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.
|
||||
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() {
|
||||
Alert.alert(t('auth.signOut'), '', [
|
||||
{ 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');
|
||||
|
||||
const sections: Section[] = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Profile-Section entfernt — Profile-Edits sind in /profile-Page direkt
|
||||
{
|
||||
key: 'theme',
|
||||
title: t('settings.section_theme'),
|
||||
rows: [
|
||||
{
|
||||
icon: 'color-palette-outline',
|
||||
iconColor: '#a78bfa',
|
||||
label: t('settings.theme'),
|
||||
sublabel: t('settings.theme_desc'),
|
||||
value: themeLabel,
|
||||
onPress: () => setThemePickerOpen(true),
|
||||
onPress: () =>
|
||||
pickFromOptions<ThemeMode>(t('settings.theme'), themeOptions, (v) =>
|
||||
setThemeMode(v),
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: 'language-outline',
|
||||
iconColor: '#a78bfa',
|
||||
label: t('settings.language'),
|
||||
sublabel: t('settings.language_desc'),
|
||||
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: [
|
||||
{
|
||||
icon: 'notifications-outline',
|
||||
iconColor: '#2563eb',
|
||||
label: t('settings.notifications_push'),
|
||||
sublabel: t('settings.notifications_push_desc'),
|
||||
soon: true,
|
||||
},
|
||||
{
|
||||
icon: 'flame-outline',
|
||||
iconColor: '#f97316',
|
||||
label: t('settings.notifications_streak'),
|
||||
sublabel: t('settings.notifications_streak_desc'),
|
||||
soon: true,
|
||||
@ -282,14 +169,12 @@ export default function SettingsScreen() {
|
||||
rows: [
|
||||
{
|
||||
icon: 'phone-portrait-outline',
|
||||
iconColor: '#16a34a',
|
||||
label: t('settings.devices'),
|
||||
sublabel: t('settings.devices_desc'),
|
||||
soon: true,
|
||||
},
|
||||
{
|
||||
icon: 'star-outline',
|
||||
iconColor: colors.brandOrange,
|
||||
label: t('settings.subscription'),
|
||||
sublabel: t('settings.subscription_desc'),
|
||||
soon: true,
|
||||
@ -302,7 +187,6 @@ export default function SettingsScreen() {
|
||||
rows: [
|
||||
{
|
||||
icon: 'mic-outline',
|
||||
iconColor: '#ec4899',
|
||||
label: t('settings.lyra_voice'),
|
||||
sublabel:
|
||||
plan === 'legend'
|
||||
@ -311,7 +195,15 @@ export default function SettingsScreen() {
|
||||
value: plan === 'legend' ? selectedVoiceName : undefined,
|
||||
// Voice picker is wired but changes are local-only until
|
||||
// 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',
|
||||
},
|
||||
],
|
||||
@ -322,14 +214,12 @@ export default function SettingsScreen() {
|
||||
rows: [
|
||||
{
|
||||
icon: 'log-out-outline',
|
||||
iconColor: colors.textMuted,
|
||||
label: t('settings.sign_out'),
|
||||
sublabel: '',
|
||||
onPress: handleSignOut,
|
||||
},
|
||||
{
|
||||
icon: 'trash-outline',
|
||||
iconColor: colors.error,
|
||||
label: t('settings.delete_account'),
|
||||
sublabel: t('settings.delete_desc'),
|
||||
destructive: true,
|
||||
@ -346,14 +236,12 @@ export default function SettingsScreen() {
|
||||
rows: [
|
||||
{
|
||||
icon: 'bug-outline',
|
||||
iconColor: '#737373',
|
||||
label: t('settings.debug_llm'),
|
||||
sublabel: t('settings.debug_llm_desc'),
|
||||
soon: true,
|
||||
},
|
||||
{
|
||||
icon: 'volume-high-outline',
|
||||
iconColor: '#737373',
|
||||
label: t('settings.debug_tts'),
|
||||
sublabel: t('settings.debug_tts_desc'),
|
||||
soon: true,
|
||||
@ -363,7 +251,7 @@ export default function SettingsScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.bg }}>
|
||||
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
|
||||
<AppHeader showBack title={t('settings.title')} />
|
||||
|
||||
<ScrollView
|
||||
@ -392,11 +280,14 @@ export default function SettingsScreen() {
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: colors.surface,
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0,0,0,0.05)',
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 3,
|
||||
elevation: 1,
|
||||
}}
|
||||
>
|
||||
{section.rows.map((row, i) => (
|
||||
@ -405,7 +296,11 @@ export default function SettingsScreen() {
|
||||
onPress={row.soon ? undefined : row.onPress}
|
||||
disabled={row.soon}
|
||||
style={({ pressed }) => ({
|
||||
width: '100%',
|
||||
opacity: row.soon ? 0.5 : pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
@ -414,29 +309,14 @@ export default function SettingsScreen() {
|
||||
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,
|
||||
backgroundColor: pressed && !row.soon ? '#f5f5f5' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
backgroundColor: row.iconColor + '18',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
flexGrow: 0,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={row.icon}
|
||||
size={18}
|
||||
color={row.destructive ? colors.error : row.iconColor}
|
||||
color={row.destructive ? colors.error : colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0, flexShrink: 1 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{
|
||||
@ -469,7 +349,6 @@ export default function SettingsScreen() {
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{t('settings.soon_badge')}
|
||||
@ -480,7 +359,6 @@ export default function SettingsScreen() {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
flexShrink: 0,
|
||||
marginLeft: 4,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
@ -492,9 +370,9 @@ export default function SettingsScreen() {
|
||||
name="chevron-forward"
|
||||
size={16}
|
||||
color="#d4d4d8"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
@ -526,33 +404,6 @@ export default function SettingsScreen() {
|
||||
{Platform.OS}
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -146,7 +146,7 @@ export function ComposeCard({ onPosted }: Props) {
|
||||
onPress={pickImage}
|
||||
android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }}
|
||||
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" />
|
||||
<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
|
||||
onPressIn={() => { if (!content.trim() || posting) return; submit(); }}
|
||||
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 }) => ({
|
||||
opacity: pressed || !content.trim() || posting ? 0.5 : 1,
|
||||
})}
|
||||
|
||||
@ -240,7 +240,6 @@ function NotificationRow({
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.65 : 1,
|
||||
backgroundColor: isUnread ? '#fff7ed' : '#ffffff',
|
||||
})}
|
||||
>
|
||||
<View
|
||||
@ -251,6 +250,7 @@ function NotificationRow({
|
||||
paddingVertical: 11,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f5f5f5',
|
||||
backgroundColor: isUnread ? '#fff7ed' : '#ffffff',
|
||||
}}
|
||||
>
|
||||
{/* Avatar-Logik:
|
||||
|
||||
244
apps/rebreak-native/components/OptionsBottomSheet.tsx
Normal file
244
apps/rebreak-native/components/OptionsBottomSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -396,20 +396,23 @@ export function PostCommentsSheet({ postId, visible, onClose }: Props) {
|
||||
onPress={submit}
|
||||
disabled={!text.trim() || submitting}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed || !text.trim() || submitting ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.brandOrange,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: pressed || !text.trim() || submitting ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Ionicons name="paper-plane" size={16} color="#fff" />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
145
apps/rebreak-native/components/WheelPickerModal.tsx
Normal file
145
apps/rebreak-native/components/WheelPickerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -328,14 +328,16 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
||||
onPress={handleAdd}
|
||||
disabled={!valid || !confirmPermanent || adding}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
marginBottom: insets.bottom > 0 ? 8 : 12,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: !valid || !confirmPermanent ? '#d4d4d4' : '#dc2626',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
{adding ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
@ -343,6 +345,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
|
||||
{t('blocker.add_sheet_title')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
@ -55,13 +55,15 @@ export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
|
||||
disabled={cancelling}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed || cancelling ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#16a34a',
|
||||
opacity: pressed || cancelling ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
{cancelling ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
@ -69,6 +71,7 @@ export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
|
||||
{t('common.cancel')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -139,6 +139,10 @@ export function DeactivationExplainerSheet({
|
||||
<Pressable
|
||||
onPress={onBreathe}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: '#16a34a',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 16,
|
||||
@ -147,13 +151,12 @@ export function DeactivationExplainerSheet({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Ionicons name="leaf" size={18} color="#fff" />
|
||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{t('blocker.deactivation_breathe_cta')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{/* Destructive secondary */}
|
||||
@ -162,9 +165,9 @@ export function DeactivationExplainerSheet({
|
||||
disabled={submitting}
|
||||
hitSlop={8}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed || submitting ? 0.5 : 1,
|
||||
alignSelf: 'center',
|
||||
paddingVertical: 12,
|
||||
opacity: pressed || submitting ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
|
||||
@ -139,6 +139,10 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
<Pressable
|
||||
onPress={onUpgradePro}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: '#eff6ff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#bfdbfe',
|
||||
@ -147,9 +151,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Ionicons name="lock-closed" size={18} color="#2563eb" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#1e3a8a' }}>
|
||||
@ -159,6 +161,7 @@ export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Pro
|
||||
{t('blocker.domain_limit_desc')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
|
||||
@ -92,21 +92,24 @@ export function ProtectionCard({ state, loading, onActivate, onPressSettings }:
|
||||
onPress={onPressSettings}
|
||||
hitSlop={10}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
accessibilityLabel={t('blocker.protection_settings_a11y')}
|
||||
>
|
||||
<View style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#ffffff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
})}
|
||||
accessibilityLabel={t('blocker.protection_settings_a11y')}
|
||||
>
|
||||
}}>
|
||||
<Ionicons name="settings-outline" size={18} color="#525252" />
|
||||
</View>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Switch
|
||||
|
||||
@ -341,28 +341,31 @@ export function ProtectionDetailsSheet({
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* MEHR INFO – outline button, Icon + Label nebeneinander (flex-row, NICHT col) */}
|
||||
{/* MEHR INFO – outline button: Pressable=card, inner View=flex-row */}
|
||||
<Pressable
|
||||
onPress={onRequestDeactivation}
|
||||
style={({ pressed }) => ({
|
||||
alignSelf: 'stretch',
|
||||
marginTop: 4,
|
||||
opacity: pressed ? 0.75 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
borderColor: HERO_COLOR,
|
||||
backgroundColor: pressed ? '#fed7aa' : '#fff7ed',
|
||||
backgroundColor: '#fff7ed',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Ionicons name="information-circle-outline" size={18} color={HERO_COLOR} />
|
||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: HERO_COLOR }}>
|
||||
{t('blocker.more_info_title')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
@ -670,17 +673,15 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||
<Pressable
|
||||
onPress={() => setOpen((v) => !v)}
|
||||
style={({ pressed }) => ({
|
||||
alignSelf: 'stretch',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
backgroundColor: pressed ? '#fafafa' : '#fff',
|
||||
opacity: pressed ? 0.75 : 1,
|
||||
})}
|
||||
>
|
||||
<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 }}>
|
||||
<View style={{ flex: 1, paddingRight: 12 }}>
|
||||
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', lineHeight: 18 }}>
|
||||
{question}
|
||||
</Text>
|
||||
</View>
|
||||
<Animated.View
|
||||
style={{
|
||||
width: 28,
|
||||
@ -694,6 +695,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||||
>
|
||||
<Ionicons name="chevron-down" size={16} color="#525252" />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Pressable>
|
||||
{open && (
|
||||
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
|
||||
|
||||
@ -76,21 +76,24 @@ export function ProtectionLockedCard({ state, onPressSettings }: Props) {
|
||||
onPress={onPressSettings}
|
||||
hitSlop={10}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
})}
|
||||
accessibilityLabel={t('blocker.protection_settings_a11y')}
|
||||
>
|
||||
<View style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#ffffff',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
})}
|
||||
accessibilityLabel={t('blocker.protection_settings_a11y')}
|
||||
>
|
||||
}}>
|
||||
<Ionicons name="settings-outline" size={18} color="#525252" />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
|
||||
@ -31,18 +31,21 @@ export function GameCard({
|
||||
onPress={() => onPress(id)}
|
||||
style={({ pressed }) => ({
|
||||
width: '100%',
|
||||
transform: [{ scale: pressed ? 0.97 : 1 }],
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: pressed ? '#f0f9ff' : '#fafafa',
|
||||
backgroundColor: '#fafafa',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 12,
|
||||
gap: 12,
|
||||
transform: [{ scale: pressed ? 0.97 : 1 }],
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<SvgXml xml={svg} width={56} height={56} />
|
||||
<View style={{ alignItems: 'center', gap: 2 }}>
|
||||
<Text
|
||||
@ -72,6 +75,7 @@ export function GameCard({
|
||||
<GameRatingStars avg={avgStars} count={count} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@ -171,9 +171,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
||||
void item.onSelect();
|
||||
}}
|
||||
android_ripple={{ color: '#e5e7eb' }}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
|
||||
})}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
@ -208,9 +206,7 @@ export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props)
|
||||
<Pressable
|
||||
onPress={handleLogout}
|
||||
android_ripple={{ color: '#e5e7eb' }}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
|
||||
})}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@ -318,6 +318,10 @@ function ProviderGrid({
|
||||
onPress={() => onSelect(p)}
|
||||
style={({ pressed }) => ({
|
||||
width: '47%',
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
@ -326,9 +330,7 @@ function ProviderGrid({
|
||||
borderColor: '#e5e5e5',
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<View
|
||||
style={{
|
||||
width: 36,
|
||||
@ -350,6 +352,7 @@ function ProviderGrid({
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={14} color="#d4d4d4" />
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
@ -583,15 +586,17 @@ function FormView({
|
||||
onPress={onConnect}
|
||||
disabled={!canConnect}
|
||||
style={({ pressed }) => ({
|
||||
backgroundColor: canConnect ? '#007AFF' : '#d4d4d4',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
marginTop: 4,
|
||||
marginBottom: insets.bottom > 0 ? 8 : 12,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: canConnect ? '#007AFF' : '#d4d4d4',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
{connecting ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
@ -599,6 +604,7 @@ function FormView({
|
||||
{t('mail.form_connect_btn')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@ -224,13 +224,15 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
||||
disabled={!password.trim() || connecting}
|
||||
style={({ pressed }) => ({
|
||||
marginTop: 4,
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
|
||||
alignItems: 'center',
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
{connecting ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
@ -238,6 +240,7 @@ export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Pro
|
||||
{t('mail.edit_account_save')}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<View style={{ height: insets.bottom }} />
|
||||
|
||||
@ -82,18 +82,21 @@ export function MailEmptyState({ onConnectPress }: Props) {
|
||||
<Pressable
|
||||
onPress={onConnectPress}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
alignSelf: 'stretch',
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 14,
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 28,
|
||||
alignSelf: 'stretch',
|
||||
alignItems: 'center',
|
||||
opacity: pressed ? 0.85 : 1,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{t('mail.empty_state_cta')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
@ -29,36 +29,34 @@ export function ApprovedDomainsList({ domains, loading }: Props) {
|
||||
<View style={{ marginHorizontal: 16, marginTop: 12 }}>
|
||||
<Pressable
|
||||
onPress={toggle}
|
||||
style={({ pressed }) => ({
|
||||
alignSelf: 'stretch',
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 14,
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
borderRadius: 12,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name="shield-checkmark-outline"
|
||||
size={16}
|
||||
color={colors.textMuted}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ flex: 1, fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
Approved Domains{' '}
|
||||
<Text style={{ color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||
({domains.length})
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={expanded ? 'chevron-up' : 'chevron-down'}
|
||||
size={16}
|
||||
size={18}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{expanded ? (
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
TextInput,
|
||||
Modal,
|
||||
Switch,
|
||||
LayoutAnimation,
|
||||
Platform,
|
||||
UIManager,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { getCitiesForBundesland } from '../../lib/germanCities';
|
||||
import { WheelPickerModal } from '../WheelPickerModal';
|
||||
import { colors } from '../../lib/theme';
|
||||
import type { Plan } from '../../hooks/useUserPlan';
|
||||
|
||||
// Geburtsjahr-Optionen: 2010 (oldest 13y) → 1920, descending (neueste oben)
|
||||
const BIRTH_YEAR_OPTIONS = Array.from({ length: 91 }, (_, i) => 2010 - i).map((y) => ({
|
||||
value: y,
|
||||
label: String(y),
|
||||
}));
|
||||
|
||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
}
|
||||
@ -22,7 +28,10 @@ export type Demographics = {
|
||||
birthYear: number | null;
|
||||
gender: string | null;
|
||||
maritalStatus: string | null;
|
||||
profession: string | null;
|
||||
employmentStatus: string | null;
|
||||
shiftWork: boolean | null;
|
||||
industry: string | null;
|
||||
jobTenure: string | null;
|
||||
bundesland: string | null;
|
||||
city: string | null;
|
||||
};
|
||||
@ -36,12 +45,10 @@ type Props = {
|
||||
onRevokeConsent?: () => void;
|
||||
};
|
||||
|
||||
// Select-Optionen — Display-Label DE, value für DB-Persistenz
|
||||
const GENDER_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: 'männlich', value: 'male' },
|
||||
{ label: 'weiblich', value: 'female' },
|
||||
{ label: 'divers', value: 'diverse' },
|
||||
{ label: 'keine Angabe', value: 'none' },
|
||||
];
|
||||
|
||||
const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
@ -53,7 +60,38 @@ const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ 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 }> = [
|
||||
{ label: 'Baden-Württemberg', value: 'BW' },
|
||||
{ label: 'Bayern', value: 'BY' },
|
||||
@ -73,39 +111,38 @@ const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
|
||||
{ label: 'Thüringen', value: 'TH' },
|
||||
];
|
||||
|
||||
const FIELD_WHY: Record<keyof Demographics, string> = {
|
||||
birthYear:
|
||||
'Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe.',
|
||||
gender: 'Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel.',
|
||||
profession:
|
||||
'Schichtarbeit, Banking-Stress, Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext.',
|
||||
maritalStatus:
|
||||
'Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir.',
|
||||
bundesland: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
|
||||
city: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
|
||||
};
|
||||
const STATUS_WITH_SHIFT: Array<string> = ['employed', 'self_employed'];
|
||||
const STATUS_WITH_INDUSTRY: Array<string> = ['employed', 'self_employed', 'in_training'];
|
||||
const STATUS_WITH_TENURE: Array<string> = ['employed', 'self_employed'];
|
||||
|
||||
function relevantFieldCount(d: Demographics): { filled: number; total: number } {
|
||||
const base = [d.birthYear !== null, !!d.gender, !!d.maritalStatus, !!d.employmentStatus, !!d.bundesland, !!d.city];
|
||||
let filled = base.filter(Boolean).length;
|
||||
let total = base.length;
|
||||
|
||||
const status = d.employmentStatus;
|
||||
if (status && STATUS_WITH_SHIFT.includes(status)) {
|
||||
total += 1;
|
||||
if (d.shiftWork !== null) filled += 1;
|
||||
}
|
||||
if (status && STATUS_WITH_INDUSTRY.includes(status)) {
|
||||
total += 1;
|
||||
if (!!d.industry) filled += 1;
|
||||
}
|
||||
if (status && STATUS_WITH_TENURE.includes(status)) {
|
||||
total += 1;
|
||||
if (!!d.jobTenure) filled += 1;
|
||||
}
|
||||
|
||||
return { filled, total };
|
||||
}
|
||||
|
||||
function lookupLabel(options: Array<{ label: string; value: string }>, v: string | null) {
|
||||
if (!v) return null;
|
||||
return options.find((o) => o.value === v)?.label ?? v;
|
||||
}
|
||||
|
||||
function isComplete(d: Demographics) {
|
||||
return (
|
||||
d.birthYear !== null &&
|
||||
!!d.gender &&
|
||||
!!d.maritalStatus &&
|
||||
!!d.profession &&
|
||||
!!d.bundesland &&
|
||||
!!d.city
|
||||
);
|
||||
}
|
||||
|
||||
// TODO Phase C: PATCH /api/profile/me/demographics — debounced auto-save (~500ms idle).
|
||||
// Bis Endpoint live: lokaler State + onChange-Callback Richtung Parent.
|
||||
function mockPersist(_next: Demographics) {
|
||||
// no-op placeholder — Parent ruft echten Endpoint
|
||||
}
|
||||
function mockPersist(_next: Demographics) {}
|
||||
|
||||
export function DemographicsAccordion({
|
||||
demographics,
|
||||
@ -127,10 +164,16 @@ export function DemographicsAccordion({
|
||||
const expanded = expandedLocal;
|
||||
const [local, setLocal] = useState<Demographics>(demographics);
|
||||
|
||||
// Select-Sheet-State
|
||||
const [pickerField, setPickerField] = useState<keyof Demographics | null>(null);
|
||||
// Generic wheel-picker state — alle Demographics-Auswahlfelder rendern via Wheel
|
||||
// (iOS 26 hat ActionSheetIOS rendering geändert → wheel ist konsistenter UX-Pattern)
|
||||
type WheelConfig = {
|
||||
title: string;
|
||||
options: Array<{ value: string | number; label: string }>;
|
||||
value: string | number | null;
|
||||
onSelect: (v: any) => void;
|
||||
};
|
||||
const [wheelConfig, setWheelConfig] = useState<WheelConfig | null>(null);
|
||||
|
||||
// Debounce-Save Ref
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -158,22 +201,29 @@ export function DemographicsAccordion({
|
||||
setLocal(next);
|
||||
}
|
||||
|
||||
const completed = isComplete(local);
|
||||
const showProTrialBanner = plan === 'free' && completed;
|
||||
const { filled, total } = relevantFieldCount(local);
|
||||
const completed = filled === total;
|
||||
const showProTrialBanner = plan === 'free';
|
||||
const progressRatio = total > 0 ? filled / total : 0;
|
||||
|
||||
const showShiftWork = !!local.employmentStatus && STATUS_WITH_SHIFT.includes(local.employmentStatus);
|
||||
const showIndustry = !!local.employmentStatus && STATUS_WITH_INDUSTRY.includes(local.employmentStatus);
|
||||
const showJobTenure = !!local.employmentStatus && STATUS_WITH_TENURE.includes(local.employmentStatus);
|
||||
|
||||
return (
|
||||
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
|
||||
{/* Privacy-Header */}
|
||||
<Pressable
|
||||
onPress={toggle}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e5e5',
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
})}
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
@ -210,6 +260,54 @@ export function DemographicsAccordion({
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{completed ? (
|
||||
<View style={{ marginTop: 10, flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Ionicons name="heart" size={14} color={colors.brandOrange} />
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: colors.text,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
}}
|
||||
>
|
||||
Danke dass du ReBreak vertraust
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={{ marginTop: 10, flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 5,
|
||||
backgroundColor: '#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>
|
||||
</Pressable>
|
||||
|
||||
{expanded ? (
|
||||
@ -223,10 +321,7 @@ export function DemographicsAccordion({
|
||||
paddingVertical: 4,
|
||||
}}
|
||||
>
|
||||
{/* Pro-Trial-Reward-Banner — nur free + (idealerweise) nicht-vollständig.
|
||||
Wir zeigen ihn aber auch im "completed"-State als sanfte Bestätigung,
|
||||
tatsächliche Trial-Vergabe ist Backend-Sache (Phase C). */}
|
||||
{plan === 'free' ? (
|
||||
{showProTrialBanner ? (
|
||||
<View
|
||||
style={{
|
||||
marginHorizontal: 8,
|
||||
@ -250,7 +345,7 @@ export function DemographicsAccordion({
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
}}
|
||||
>
|
||||
{showProTrialBanner
|
||||
{completed
|
||||
? 'Du bekommst 1 Woche Pro geschenkt'
|
||||
: 'Vervollständige dein Profil — 1 Woche Pro geschenkt'}
|
||||
</Text>
|
||||
@ -270,105 +365,256 @@ export function DemographicsAccordion({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Birth Year — Number-Input */}
|
||||
<FieldRow
|
||||
label="Geburtsjahr"
|
||||
why={FIELD_WHY.birthYear}
|
||||
why="Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe."
|
||||
filled={local.birthYear !== null}
|
||||
>
|
||||
<TextInput
|
||||
value={local.birthYear !== null ? String(local.birthYear) : ''}
|
||||
onChangeText={(raw) => {
|
||||
const cleaned = raw.replace(/[^0-9]/g, '').slice(0, 4);
|
||||
if (cleaned === '') {
|
||||
persist({ ...local, birthYear: null });
|
||||
return;
|
||||
<SelectButton
|
||||
value={local.birthYear !== null ? String(local.birthYear) : null}
|
||||
onPress={() =>
|
||||
setWheelConfig({
|
||||
title: 'Geburtsjahr',
|
||||
options: BIRTH_YEAR_OPTIONS,
|
||||
value: local.birthYear,
|
||||
onSelect: (v) => flushSave({ ...local, birthYear: v as number }),
|
||||
})
|
||||
}
|
||||
const n = parseInt(cleaned, 10);
|
||||
// Erlaube tippen — Validierung beim Blur
|
||||
persist({ ...local, birthYear: Number.isNaN(n) ? null : n });
|
||||
}}
|
||||
onBlur={() => {
|
||||
const n = local.birthYear;
|
||||
if (n !== null && (n < 1920 || n > 2010)) {
|
||||
// ungültig — auf null zurücksetzen
|
||||
flushSave({ ...local, birthYear: null });
|
||||
}
|
||||
}}
|
||||
keyboardType="number-pad"
|
||||
maxLength={4}
|
||||
placeholder="z.B. 1989"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{/* Gender — Select */}
|
||||
<FieldRow label="Geschlecht" why={FIELD_WHY.gender}>
|
||||
<FieldRow
|
||||
label="Geschlecht"
|
||||
why="Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel."
|
||||
filled={!!local.gender}
|
||||
>
|
||||
<SelectButton
|
||||
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>
|
||||
|
||||
{/* Profession — TextInput */}
|
||||
<FieldRow label="Beruf" why={FIELD_WHY.profession}>
|
||||
<TextInput
|
||||
value={local.profession ?? ''}
|
||||
onChangeText={(t) => persist({ ...local, profession: t })}
|
||||
onBlur={() => {
|
||||
const trimmed = (local.profession ?? '').trim();
|
||||
flushSave({ ...local, profession: trimmed === '' ? null : trimmed });
|
||||
}}
|
||||
maxLength={80}
|
||||
placeholder="z.B. Pflege, IT, Schichtarbeit"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{/* Marital — Select */}
|
||||
<FieldRow label="Familienstand" why={FIELD_WHY.maritalStatus}>
|
||||
<FieldRow
|
||||
label="Familienstand"
|
||||
why="Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir."
|
||||
filled={!!local.maritalStatus}
|
||||
>
|
||||
<SelectButton
|
||||
value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)}
|
||||
onPress={() => setPickerField('maritalStatus')}
|
||||
onPress={() =>
|
||||
setWheelConfig({
|
||||
title: 'Familienstand',
|
||||
options: MARITAL_OPTIONS,
|
||||
value: local.maritalStatus,
|
||||
onSelect: (v) => flushSave({ ...local, maritalStatus: v as string }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{/* Bundesland — Select */}
|
||||
<FieldRow label="Bundesland" why={FIELD_WHY.bundesland}>
|
||||
{/* Beruf-Section */}
|
||||
<View style={{ paddingHorizontal: 14, paddingTop: 12, paddingBottom: 4 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
letterSpacing: 0.6,
|
||||
}}
|
||||
>
|
||||
BERUF
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<FieldRow
|
||||
label="Status"
|
||||
why="Schichtarbeit, Banking-Stress und Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext."
|
||||
filled={!!local.employmentStatus}
|
||||
indent
|
||||
>
|
||||
<SelectButton
|
||||
value={lookupLabel(EMPLOYMENT_STATUS_OPTIONS, local.employmentStatus)}
|
||||
onPress={() =>
|
||||
setWheelConfig({
|
||||
title: 'Berufs-Status',
|
||||
options: EMPLOYMENT_STATUS_OPTIONS,
|
||||
value: local.employmentStatus,
|
||||
onSelect: (raw) => {
|
||||
const v = raw as string;
|
||||
const next = {
|
||||
...local,
|
||||
employmentStatus: v,
|
||||
shiftWork: STATUS_WITH_SHIFT.includes(v) ? local.shiftWork : null,
|
||||
industry: STATUS_WITH_INDUSTRY.includes(v) ? local.industry : null,
|
||||
jobTenure: STATUS_WITH_TENURE.includes(v) ? local.jobTenure : null,
|
||||
};
|
||||
flushSave(next);
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
{showShiftWork ? (
|
||||
<FieldRow
|
||||
label="Schichtarbeit"
|
||||
why=""
|
||||
filled={local.shiftWork !== null}
|
||||
indent
|
||||
hideWhy
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
}}
|
||||
>
|
||||
{local.shiftWork === null ? 'k.A.' : local.shiftWork ? 'Ja' : 'Nein'}
|
||||
</Text>
|
||||
<Switch
|
||||
value={local.shiftWork === true}
|
||||
onValueChange={(v) => flushSave({ ...local, shiftWork: v })}
|
||||
trackColor={{ false: '#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,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 4,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(0,0,0,0.06)',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
letterSpacing: 0.6,
|
||||
}}
|
||||
>
|
||||
WOHNORT
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<FieldRow
|
||||
label="Bundesland"
|
||||
why=""
|
||||
filled={!!local.bundesland}
|
||||
indent
|
||||
hideWhy
|
||||
>
|
||||
<SelectButton
|
||||
value={lookupLabel(BUNDESLAND_OPTIONS, local.bundesland)}
|
||||
onPress={() => setPickerField('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>
|
||||
|
||||
{/* City — TextInput */}
|
||||
<FieldRow label="Stadt" why={FIELD_WHY.city} isLast>
|
||||
<TextInput
|
||||
value={local.city ?? ''}
|
||||
onChangeText={(t) => persist({ ...local, city: t })}
|
||||
onBlur={() => {
|
||||
const trimmed = (local.city ?? '').trim();
|
||||
flushSave({ ...local, city: trimmed === '' ? null : trimmed });
|
||||
}}
|
||||
maxLength={60}
|
||||
placeholder="z.B. München"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={inputStyle}
|
||||
{/* 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}
|
||||
|
||||
{/* Revoke Consent */}
|
||||
<Pressable
|
||||
onPress={onRevokeConsent}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
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={{
|
||||
@ -380,43 +626,20 @@ export function DemographicsAccordion({
|
||||
>
|
||||
Einwilligung widerrufen
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<SelectSheet
|
||||
visible={pickerField === 'gender'}
|
||||
title="Geschlecht"
|
||||
options={GENDER_OPTIONS}
|
||||
selectedValue={local.gender}
|
||||
onClose={() => setPickerField(null)}
|
||||
onSelect={(v) => {
|
||||
flushSave({ ...local, gender: v });
|
||||
setPickerField(null);
|
||||
}}
|
||||
/>
|
||||
<SelectSheet
|
||||
visible={pickerField === 'maritalStatus'}
|
||||
title="Familienstand"
|
||||
options={MARITAL_OPTIONS}
|
||||
selectedValue={local.maritalStatus}
|
||||
onClose={() => setPickerField(null)}
|
||||
onSelect={(v) => {
|
||||
flushSave({ ...local, maritalStatus: v });
|
||||
setPickerField(null);
|
||||
}}
|
||||
/>
|
||||
<SelectSheet
|
||||
visible={pickerField === 'bundesland'}
|
||||
title="Bundesland"
|
||||
options={BUNDESLAND_OPTIONS}
|
||||
selectedValue={local.bundesland}
|
||||
onClose={() => setPickerField(null)}
|
||||
onSelect={(v) => {
|
||||
flushSave({ ...local, bundesland: v });
|
||||
setPickerField(null);
|
||||
}}
|
||||
<WheelPickerModal
|
||||
visible={wheelConfig !== null}
|
||||
title={wheelConfig?.title ?? ''}
|
||||
options={(wheelConfig?.options ?? []) as Array<{ value: string | number; label: string }>}
|
||||
value={wheelConfig?.value ?? null}
|
||||
onSelect={(v) => wheelConfig?.onSelect(v)}
|
||||
onClose={() => setWheelConfig(null)}
|
||||
/>
|
||||
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -439,17 +662,23 @@ function FieldRow({
|
||||
label,
|
||||
why,
|
||||
isLast,
|
||||
indent,
|
||||
hideWhy,
|
||||
filled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
why: string;
|
||||
isLast?: boolean;
|
||||
indent?: boolean;
|
||||
hideWhy?: boolean;
|
||||
filled: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 14,
|
||||
paddingHorizontal: indent ? 20 : 14,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: isLast ? 0 : 1,
|
||||
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||
@ -463,18 +692,26 @@ function FieldRow({
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1 }}>
|
||||
<Ionicons
|
||||
name={filled ? 'checkmark-circle' : 'warning-outline'}
|
||||
size={14}
|
||||
color={filled ? '#16a34a' : '#f59e0b'}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
{!hideWhy && why ? (
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 6,
|
||||
@ -486,6 +723,7 @@ function FieldRow({
|
||||
>
|
||||
{why}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@ -494,140 +732,33 @@ function SelectButton({ value, onPress }: { value: string | null; onPress: () =>
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: 8,
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||
{/* Value als Chip */}
|
||||
<View
|
||||
style={{
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: '#f4f4f5',
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ececec',
|
||||
minWidth: 140,
|
||||
justifyContent: 'flex-end',
|
||||
})}
|
||||
borderColor: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
color: value ? colors.text : colors.textMuted,
|
||||
fontFamily: value ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{value ?? 'auswählen'}
|
||||
</Text>
|
||||
<Ionicons name="chevron-down" size={14} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSheet({
|
||||
visible,
|
||||
title,
|
||||
options,
|
||||
selectedValue,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
selectedValue: string | null;
|
||||
onClose: () => void;
|
||||
onSelect: (v: string) => void;
|
||||
}) {
|
||||
const sortedOptions = useMemo(() => options, [options]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
/* swallow */
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderTopLeftRadius: 18,
|
||||
borderTopRightRadius: 18,
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 24,
|
||||
maxHeight: '70%',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} hitSlop={10}>
|
||||
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView style={{ maxHeight: 380 }}>
|
||||
{sortedOptions.map((opt) => {
|
||||
const isSelected = opt.value === selectedValue;
|
||||
return (
|
||||
<Pressable
|
||||
key={opt.value}
|
||||
onPress={() => onSelect(opt.value)}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 10,
|
||||
backgroundColor: isSelected ? '#f5f8ff' : 'transparent',
|
||||
})}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
{isSelected ? (
|
||||
<Ionicons name="checkmark" size={18} color={colors.brandOrange} />
|
||||
) : null}
|
||||
{/* Chevron-right am Ende, separat vom Chip */}
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,12 +63,14 @@ export function DigaMissionBanner({ onDismiss, onContribute }: Props) {
|
||||
onPress={onContribute}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
backgroundColor: '#854d0e',
|
||||
borderRadius: 8,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@ -78,16 +80,19 @@ export function DigaMissionBanner({ onDismiss, onContribute }: Props) {
|
||||
>
|
||||
Beitragen
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onDismiss}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 7,
|
||||
borderRadius: 8,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@ -97,6 +102,7 @@ export function DigaMissionBanner({ onDismiss, onContribute }: Props) {
|
||||
>
|
||||
Später
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@ -1,10 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { View, Text, Pressable, Image } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
import { colors } from '../../lib/theme';
|
||||
import { resolveAvatar } from '../../lib/resolveAvatar';
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
@ -14,6 +34,7 @@ type Props = {
|
||||
plan: Plan;
|
||||
memberSince: string;
|
||||
provider: AuthProvider;
|
||||
demographicsComplete?: boolean;
|
||||
showDemographicsHint?: boolean;
|
||||
onEditAvatar?: () => void;
|
||||
onEditNickname?: () => void;
|
||||
@ -26,10 +47,11 @@ const planLabel: Record<Plan, string> = {
|
||||
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' },
|
||||
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({
|
||||
@ -39,6 +61,7 @@ export function ProfileHeader({
|
||||
plan,
|
||||
memberSince,
|
||||
provider,
|
||||
demographicsComplete,
|
||||
showDemographicsHint,
|
||||
onEditAvatar,
|
||||
onEditNickname,
|
||||
@ -57,8 +80,9 @@ export function ProfileHeader({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
|
||||
{/* Avatar — Pressable; Camera-Badge ist eigene Pressable (vorher nur dekoratives View) */}
|
||||
<View style={{ paddingVertical: 24, paddingHorizontal: 20 }}>
|
||||
{/* Avatar — Pressable in alignSelf:center-Wrapper (Pressable+style-fn ignoriert alignSelf manchmal in RN) */}
|
||||
<View style={{ alignSelf: 'center' }}>
|
||||
<Pressable
|
||||
onPress={onEditAvatar}
|
||||
style={({ pressed }) => ({
|
||||
@ -92,7 +116,7 @@ export function ProfileHeader({
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Camera-Badge — iOS-Photos-Pattern: blauer Kreis, weißes Icon */}
|
||||
{/* Camera-Badge — iOS-Photos-Pattern: schwarzer Kreis, weißes Icon */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
@ -112,8 +136,10 @@ export function ProfileHeader({
|
||||
<Ionicons name="camera" size={14} color="#ffffff" />
|
||||
</View>
|
||||
</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
|
||||
onPress={onEditNickname}
|
||||
hitSlop={8}
|
||||
@ -134,13 +160,52 @@ export function ProfileHeader({
|
||||
>
|
||||
{nickname}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Plan-Tier-Badge direkt unter Nickname — Legend mit sparkles-icon */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'center',
|
||||
marginTop: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: plan === 'legend' ? 14 : 12,
|
||||
paddingVertical: plan === 'legend' ? 6 : 4,
|
||||
borderRadius: 999,
|
||||
backgroundColor: planStyle.bg,
|
||||
borderWidth: 1.5,
|
||||
borderColor: planStyle.border,
|
||||
}}
|
||||
>
|
||||
{planStyle.icon ? (
|
||||
<Ionicons name={planStyle.icon} size={13} color={planStyle.text} />
|
||||
) : null}
|
||||
<Text
|
||||
style={{
|
||||
fontSize: plan === 'legend' ? 13 : 11,
|
||||
color: planStyle.text,
|
||||
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={{
|
||||
marginTop: 4,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
@ -152,11 +217,7 @@ export function ProfileHeader({
|
||||
borderColor: '#e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Ionicons
|
||||
name={provider === 'apple' ? 'logo-apple' : 'logo-google'}
|
||||
size={11}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
{provider === 'google' ? <GoogleIcon /> : <AppleIcon />}
|
||||
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||
{providerPillLabel}
|
||||
</Text>
|
||||
@ -164,7 +225,6 @@ export function ProfileHeader({
|
||||
) : (
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
@ -173,71 +233,71 @@ export function ProfileHeader({
|
||||
{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,
|
||||
fontSize: 12,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
}}
|
||||
>
|
||||
{planLabel[plan].toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||
Mitglied seit {memberSince}
|
||||
</Text>
|
||||
</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 ? (
|
||||
<Pressable
|
||||
onPress={onDemographicsHintPress}
|
||||
hitSlop={6}
|
||||
style={({ pressed }) => ({
|
||||
alignSelf: 'stretch',
|
||||
width: '100%',
|
||||
marginTop: 14,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f5f8ff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#dbe5ff',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
alignSelf: 'center',
|
||||
marginTop: 16,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
})}
|
||||
>
|
||||
<Ionicons name="heart-outline" size={16} color={colors.brandOrange} style={{ flexShrink: 0 }} />
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: 12,
|
||||
color: colors.text,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
lineHeight: 17,
|
||||
backgroundColor: '#fcd34d',
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#d97706',
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
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" />
|
||||
<View>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: '#92400e',
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
lineHeight: 18,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Hilf uns rebreak besser zu machen
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} style={{ flexShrink: 0 }} />
|
||||
<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>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { View, Text, Pressable } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { colors } from '../../lib/theme';
|
||||
|
||||
type Props = {
|
||||
@ -14,70 +13,59 @@ type Props = {
|
||||
type CardProps = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ComponentProps<typeof Ionicons>['name'];
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
function StatCard({ value, label, icon, onPress }: CardProps) {
|
||||
function StatPill({ value, label, onPress }: CardProps) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ pressed }) => ({
|
||||
flex: 1,
|
||||
opacity: pressed ? 0.6 : 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 8,
|
||||
})}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||
>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 30,
|
||||
fontSize: 18,
|
||||
color: colors.text,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
letterSpacing: -0.5,
|
||||
letterSpacing: -0.2,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{icon ? (
|
||||
<Ionicons name={icon} size={16} color={colors.textMuted} style={{ marginTop: 4 }} />
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 12,
|
||||
marginTop: 1,
|
||||
fontSize: 10,
|
||||
color: colors.textMuted,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
</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({
|
||||
postsCount,
|
||||
followersCount,
|
||||
@ -87,27 +75,21 @@ export function StatsBar({
|
||||
onApprovedDomainsPress,
|
||||
}: Props) {
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 16, alignItems: 'center' }}>
|
||||
<View style={{ paddingHorizontal: 16 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: '#ececec',
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<StatCard value={String(postsCount)} label="Posts" onPress={onPostsPress} />
|
||||
<Divider />
|
||||
<StatCard value={String(followersCount)} label="Follower" onPress={onFollowersPress} />
|
||||
<Divider />
|
||||
<StatCard
|
||||
<StatPill value={String(postsCount)} label="Posts" onPress={onPostsPress} />
|
||||
<StatPill value={String(followersCount)} label="Follower" onPress={onFollowersPress} />
|
||||
<StatPill
|
||||
value={String(approvedDomainsCount)}
|
||||
label="Approved Domains"
|
||||
icon="trophy-outline"
|
||||
label="Domains"
|
||||
onPress={onApprovedDomainsPress}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@ -39,16 +39,19 @@ export function GamePickerGrid({ onSelect }: { onSelect: (game: GameType) => voi
|
||||
onPress={() => onSelect(game.id)}
|
||||
style={({ pressed }) => ({
|
||||
width: '47%',
|
||||
opacity: pressed ? 0.75 : 1,
|
||||
})}
|
||||
>
|
||||
<View style={{
|
||||
aspectRatio: 1,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e5e7eb',
|
||||
backgroundColor: pressed ? '#f0f9ff' : '#f9fafb',
|
||||
backgroundColor: '#f9fafb',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 12,
|
||||
})}
|
||||
>
|
||||
}}>
|
||||
<SvgXml xml={game.svg} width={56} height={56} />
|
||||
<Text style={{ marginTop: 10, fontFamily: 'Nunito_700Bold', color: '#111827', fontSize: 14 }}>
|
||||
{t(game.titleKey)}
|
||||
@ -67,6 +70,7 @@ export function GamePickerGrid({ onSelect }: { onSelect: (game: GameType) => voi
|
||||
>
|
||||
{t(game.descKey)}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
89
apps/rebreak-native/lib/germanCities.ts
Normal file
89
apps/rebreak-native/lib/germanCities.ts
Normal 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] ?? [];
|
||||
}
|
||||
48
apps/rebreak-native/lib/useNativeActionSheet.ts
Normal file
48
apps/rebreak-native/lib/useNativeActionSheet.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -645,5 +645,45 @@
|
||||
"label_one": "Tag",
|
||||
"label_other": "Tage",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -645,5 +645,45 @@
|
||||
"label_one": "day",
|
||||
"label_other": "days",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,9 +13,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/nunito": "^0.2.3",
|
||||
"@expo/react-native-action-sheet": "^4.1.1",
|
||||
"@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-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",
|
||||
"@supabase/supabase-js": "^2.46.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
|
||||
78
apps/rebreak-native/scripts/build-ios-clean.sh
Executable file
78
apps/rebreak-native/scripts/build-ios-clean.sh
Executable 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 "$@"
|
||||
@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev:backend": "pnpm --filter rebreak-backend dev",
|
||||
"dev:native": "pnpm --filter rebreak-native start",
|
||||
"dev:admin": "pnpm --filter rebreak-admin dev",
|
||||
"build:backend": "pnpm --filter rebreak-backend build",
|
||||
"build:admin": "pnpm --filter rebreak-admin build",
|
||||
"android": "pnpm --filter rebreak-native android",
|
||||
"ios": "pnpm --filter rebreak-native ios"
|
||||
},
|
||||
|
||||
6374
pnpm-lock.yaml
generated
6374
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user