initial commit: rebreak-monorepo (RN app + standalone Nitro backend)

This commit is contained in:
RaynisDev 2026-05-06 07:13:43 +02:00
commit b58588cf3c
330 changed files with 46879 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
node_modules
.output
.output-staging
.nitro
.nuxt
.pnpm-store
# RN / Expo
apps/rebreak-native/ios/Pods
apps/rebreak-native/ios/build
apps/rebreak-native/ios/DerivedData
apps/rebreak-native/android/build
apps/rebreak-native/android/app/build
apps/rebreak-native/android/.gradle
apps/rebreak-native/.expo
# Build artefacts
*.log
*.tsbuildinfo
backend/server/generated
# OS
.DS_Store
Thumbs.db
# Local env
.env
.env.local
*.local

37
apps/rebreak-native/.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
ios/
android/
*.jks
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# Storybook
storybook-static/

View File

@ -0,0 +1,2 @@
node-linker=hoisted
shamefully-hoist=true

View File

@ -0,0 +1,112 @@
# Rebreak Native (React Native + Expo)
> Mobile App für iOS + Android. Migration von Nuxt+Capacitor → React Native+Expo.
> Migration-Plan: [`apps/rebreak/docs/react-native-migration.md`](../rebreak/docs/react-native-migration.md)
## Status
**Phase 1 — Foundation Skeleton** (2026-05-02)
- [x] Verzeichnisstruktur
- [x] `package.json` mit Expo SDK 53 + RN 0.76 (New Architecture)
- [x] `app.config.ts` (Expo, Bundle-ID `org.rebreak.app`)
- [x] Expo Router Skeleton
- [x] NativeWind Setup (Tailwind für RN)
- [x] Metro Config (pnpm Monorepo aware)
- [x] Supabase Client + API Wrapper
- [ ] `pnpm install` — vom User auszuführen
- [ ] `expo prebuild` — generiert `ios/` + `android/` Bare-Projekte
- [ ] Native Module Imports (NEFilter, VpnService, A11y) — Phase 5+
## Setup
```bash
# vom Monorepo-Root:
pnpm install
# Native-Projekte generieren (lokal, nicht committed)
cd apps/rebreak-native
pnpm prebuild
# Run
pnpm ios # iOS Simulator
pnpm android # Android Emulator
```
## Stack
| Bereich | Lib |
|---|---|
| Framework | React Native 0.76 (New Architecture) + Expo SDK 53 |
| Routing | Expo Router (file-based) |
| State | Zustand |
| Server State | TanStack Query (React Query) |
| Styling | NativeWind 4 (Tailwind) |
| Forms | React Hook Form + Valibot |
| i18n | react-i18next |
| Auth | @supabase/supabase-js + AsyncStorage |
| Animation | react-native-reanimated, lottie-react-native |
| Storage | react-native-mmkv (fast key-value) |
## Verzeichnisstruktur
```
apps/rebreak-native/
├── app/ # Expo Router (file-based routes)
│ ├── _layout.tsx # Root Stack
│ ├── index.tsx # Landing (auth oder app entscheiden)
│ ├── (auth)/ # Auth-Flow (signin, signup, forgot-password)
│ └── (app)/ # Authenticated App (tabs)
├── components/ # Reusable UI Components
├── hooks/ # Custom React Hooks
├── stores/ # Zustand Stores
├── lib/ # Supabase Client, API Wrapper, Utils
│ ├── supabase.ts
│ └── api.ts
├── locales/ # de.json, en.json
├── modules/ # Custom Expo Native Modules
│ ├── rebreak-ios-filter/ # NEFilter + Tunnel (Phase 5)
│ ├── rebreak-android-blocker/ # VpnService + DnsFilter (Phase 6)
│ └── rebreak-android-a11y/ # AccessibilityService (Phase 6)
├── plugins/ # Expo Config Plugins
└── assets/ # Icons, Splashscreens, Fonts
```
## Wichtige Konfiguration
| Datei | Zweck |
|---|---|
| `app.config.ts` | Expo App-Config (Bundle-ID, Permissions, Plugins) |
| `metro.config.js` | Monorepo-aware Metro (Symlinks, Workspace-Folders) |
| `babel.config.js` | NativeWind Preset + Reanimated Plugin |
| `tailwind.config.js` | Tailwind Config — Brand-Colors aus apps/rebreak/ syncen |
| `global.css` | NativeWind Tailwind-Imports |
## Native Module Strategie
Bestehender Native-Code aus `apps/rebreak/ios/` + `apps/rebreak/android/` wird in Expo Native Modules gewrappt — **ohne neu zu schreiben**.
### iOS — `modules/rebreak-ios-filter/`
Wrapped:
- `RebreakURLFilter` (NEFilterDataProvider) → blockt bet365 etc.
- `RebreakTunnel` (NEPacketTunnelProvider) → DNS-Filter
### Android — `modules/rebreak-android-blocker/`
Wrapped:
- `RebreakVpnService` (Kotlin) → VpnService DNS-Filter
- `DnsFilter` + `HashList` + `DomainHasher`
### Android — `modules/rebreak-android-a11y/`
Wrapped:
- `RebreakAccessibilityService` → Detection von Gambling-Apps
## ⚠️ Wichtige Hinweise
- **Schutz-Stack bleibt 1:1 erhalten** — Swift- und Kotlin-Code wandert unverändert in `modules/`.
- **Backend bleibt** in `apps/rebreak/server/` — RN ruft die gleichen Endpoints.
- **Alte Capacitor-App** bleibt deployed bis RN-App im Store ist.
- **Kein Auto-Commit** — User entscheidet wann committet wird.
## Phasen-Tracker
Siehe Migration-Plan für Details: [`apps/rebreak/docs/react-native-migration.md`](../rebreak/docs/react-native-migration.md)

View File

@ -0,0 +1,105 @@
import { ExpoConfig, ConfigContext } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "ReBreak",
slug: "rebreak",
version: "0.1.0",
orientation: "portrait",
icon: "./assets/icon.png",
scheme: "rebreak",
userInterfaceStyle: "automatic",
newArchEnabled: true,
splash: {
image: "./assets/splash.png",
resizeMode: "contain",
backgroundColor: "#0f172a",
},
ios: {
supportsTablet: true,
bundleIdentifier: "org.rebreak.app",
config: {
usesNonExemptEncryption: false,
},
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
NSMicrophoneUsageDescription:
"Rebreak nutzt das Mikrofon für Sprachnachrichten an Lyra.",
NSPhotoLibraryUsageDescription:
"Rebreak greift auf Fotos zu, damit du sie in deinen Posts teilen kannst.",
NSPhotoLibraryAddUsageDescription:
"Rebreak speichert Bilder in deine Foto-Mediathek.",
},
},
android: {
package: "org.rebreak.app",
adaptiveIcon: {
// Chain-only foreground (ohne "ReBreak"-Schrift). Auf Android schneidet die
// Adaptive-Icon-Mask (Kreis/Squircle/etc.) den Untertitel sonst ab.
foregroundImage: "./assets/adaptive-icon-android.png",
backgroundColor: "#0a0a0a",
},
permissions: [
"INTERNET",
"ACCESS_NETWORK_STATE",
"BIND_VPN_SERVICE",
"FOREGROUND_SERVICE",
"POST_NOTIFICATIONS",
"BIND_ACCESSIBILITY_SERVICE",
"RECORD_AUDIO",
],
},
plugins: [
"expo-router",
"expo-localization",
[
"expo-build-properties",
{
ios: {
deploymentTarget: "15.1",
useFrameworks: "static",
},
android: {
minSdkVersion: 26,
compileSdkVersion: 35,
targetSdkVersion: 35,
},
},
],
// Xcode 16 + RN 0.79 fmt consteval workaround
"./plugins/with-fmt-consteval-fix",
// Phase 5: NEFilter Extension + Family Controls Entitlements (iOS)
"./plugins/with-rebreak-protection-ios",
// Phase 5: VpnService + AccessibilityService (Android)
"./plugins/with-rebreak-protection-android",
// Rive-Asset (lyra-avatar.riv) als Android raw-resource bundlen
"./plugins/with-rive-asset-android",
],
experiments: {
typedRoutes: true,
},
extra: {
apiUrl:
process.env.EXPO_PUBLIC_API_URL ||
process.env.API_URL ||
"https://staging.rebreak.org",
// TEMP: Staging Anon-Key + URL hardcoded für lokales Dev-Testing.
// Anon-Key ist designed für Client-Ship (RLS protectiert DB). Trotzdem:
// BFF-Migration kommt in Phase 5 — dann fliegen diese 2 Zeilen wieder raus.
supabaseUrl:
process.env.EXPO_PUBLIC_SUPABASE_URL ||
process.env.SUPABASE_URL ||
"https://db-staging.rebreak.org",
supabaseAnonKey:
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ||
process.env.SUPABASE_KEY ||
process.env.SUPABASE_ANON_KEY ||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsImF1ZCI6ImF1dGhlbnRpY2F0ZWQiLCJyb2xlIjoiYW5vbiIsImV4cCI6MjA5MTAxODk1NSwiaWF0IjoxNzc1NjU4OTU1fQ.93d2r3pft2E-alf1JezqueD0l0n1dim7dGvhBN0l1Cs",
},
});

View File

@ -0,0 +1,217 @@
import { useEffect, useRef, useState } from 'react';
import { View, ActivityIndicator, AppState, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { useNotificationStore } from '../../stores/notifications';
import { colors } from '../../lib/theme';
import { NativeTabs } from '../../components/NativeTabs';
import { protection } from '../../lib/protection';
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
export default function AppLayout() {
const router = useRouter();
const { t } = useTranslation();
const { session, loading } = useAuthStore();
const loadNotifications = useNotificationStore((s) => s.load);
const startRealtime = useNotificationStore((s) => s.startRealtime);
const stopRealtime = useNotificationStore((s) => s.stopRealtime);
const resetNotifications = useNotificationStore((s) => s.reset);
const rearmInFlightRef = useRef(false);
const bypassNotifiedRef = useRef(false);
// Android-Tab-Icons müssen async aus Ionicons-Font generiert werden (kein
// SF-Symbol-Support). preloadTabIcons() läuft schon beim Modul-Import — hier
// nur den ready-State tracken damit wir re-rendern wenn der Cache fertig ist.
const [tabIconsReady, setTabIconsReady] = useState(Platform.OS !== 'android');
useEffect(() => {
if (Platform.OS === 'android' && !tabIconsReady) {
preloadTabIcons().then(() => setTabIconsReady(true));
}
}, [tabIconsReady]);
useEffect(() => {
if (!loading && !session) {
router.replace('/signin');
}
}, [session, loading]);
useEffect(() => {
if (!session) {
resetNotifications();
return;
}
loadNotifications();
startRealtime();
return () => {
stopRealtime();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.user?.id]);
useEffect(() => {
if (!session || Platform.OS !== 'ios') return;
let cancelled = false;
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function notifyBypassDetected(): Promise<boolean> {
const perms = await Notifications.getPermissionsAsync();
let granted = perms.granted || perms.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL;
if (!granted) {
const req = await Notifications.requestPermissionsAsync();
granted = req.granted || req.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL;
}
if (!granted) return false;
await Notifications.scheduleNotificationAsync({
content: {
title: 'ReBreak Schutz manipuliert',
body: 'Tippe hier, um den Schutz sofort wieder zu aktivieren.',
sound: 'default',
data: { type: 'protection_bypass_detected' },
},
trigger: null,
});
return true;
}
async function enforceProtection() {
if (cancelled || rearmInFlightRef.current) return;
try {
const state = await protection.getCombinedState();
if (cancelled) return;
if (state.phase !== 'recoveringFromBypass') {
bypassNotifiedRef.current = false;
return;
}
if (bypassNotifiedRef.current) return;
bypassNotifiedRef.current = true;
const notified = await notifyBypassDetected();
if (!notified) {
// Fallback wenn Notifications nicht erlaubt sind.
rearmInFlightRef.current = true;
router.replace('/blocker');
await protection.activateFamilyControls().catch(() => ({ enabled: false }));
}
} finally {
rearmInFlightRef.current = false;
}
}
async function onBypassNotificationTap() {
if (rearmInFlightRef.current) return;
rearmInFlightRef.current = true;
try {
router.replace('/blocker');
await protection.activateFamilyControls().catch(() => ({ enabled: false }));
} finally {
rearmInFlightRef.current = false;
}
}
// Initial check + foreground re-check + periodisches Polling als Fallback.
enforceProtection();
const notifTapSub = Notifications.addNotificationResponseReceivedListener((response) => {
const type = response.notification.request.content.data?.type;
if (type === 'protection_bypass_detected') {
void onBypassNotificationTap();
}
});
Notifications.getLastNotificationResponseAsync().then((response) => {
const type = response?.notification.request.content.data?.type;
if (type === 'protection_bypass_detected') {
void onBypassNotificationTap();
}
});
const appStateSub = AppState.addEventListener('change', (s) => {
if (s === 'active') {
enforceProtection();
}
});
pollTimer = setInterval(enforceProtection, 15000);
return () => {
cancelled = true;
notifTapSub.remove();
appStateSub.remove();
if (pollTimer) clearInterval(pollTimer);
};
}, [session, router]);
if (loading || !session) {
return (
<View className="flex-1 bg-white items-center justify-center">
<ActivityIndicator color={colors.brandOrange} size="large" />
</View>
);
}
return (
<NativeTabs
sidebarAdaptable
hapticFeedbackEnabled
tabBarActiveTintColor={colors.brandOrange}
tabBarInactiveTintColor="#d1d1d6"
scrollEdgeAppearance="default"
tabLabelStyle={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 11,
}}
>
<NativeTabs.Screen
name="index"
options={{
title: t('tabs.home'),
tabBarIcon: () =>
Platform.OS === 'ios'
? { sfSymbol: 'house.fill' }
: (getTabIcon('home') as any),
}}
/>
<NativeTabs.Screen
name="chat"
options={{
title: t('tabs.chat'),
tabBarIcon: () =>
Platform.OS === 'ios'
? { sfSymbol: 'bubble.left.and.bubble.right.fill' }
: (getTabIcon('chat') as any),
}}
/>
<NativeTabs.Screen
name="coach"
options={{
title: t('tabs.coach'),
tabBarIcon: () =>
Platform.OS === 'ios'
? { sfSymbol: 'sparkles' }
: (getTabIcon('coach') as any),
}}
/>
<NativeTabs.Screen
name="blocker"
options={{
title: t('tabs.blocker'),
tabBarIcon: () =>
Platform.OS === 'ios'
? { sfSymbol: 'checkmark.shield.fill' }
: (getTabIcon('blocker') as any),
}}
/>
<NativeTabs.Screen
name="mail"
options={{
title: t('tabs.mail'),
tabBarIcon: () =>
Platform.OS === 'ios'
? { sfSymbol: 'envelope.fill' }
: (getTabIcon('mail') as any),
}}
/>
<NativeTabs.Screen name="notifications" options={{ href: null }} />
</NativeTabs>
);
}

View File

@ -0,0 +1,310 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ScrollView, View, Alert, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
import { AppHeader } from '../../components/AppHeader';
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
import { DomainGrid } from '../../components/blocker/DomainGrid';
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection } from '../../lib/protection';
export default function BlockerScreen() {
const router = useRouter();
const { t } = useTranslation();
// react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View
// erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding
// hinterm Tab-Bar verschwinden.
const tabBarHeight = useBottomTabBarHeight();
const {
state,
loading,
cooldownRemainingFormatted,
refresh,
activateUrlFilter,
activateFamilyControls,
requestDeactivation,
cancelDeactivation,
} = useProtectionState();
const plan = state?.plan ?? 'free';
const {
domains,
tier,
addDomain,
submitDomain,
refresh: refreshDomains,
} = useCustomDomains(plan);
const { sync: syncBlocklist } = useBlocklistSync();
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
// Bei domain_rejected wird die Row backend-seitig hard-deleted → refetch
// entfernt sie aus der Liste. Zusätzlich blocklist.bin neu syncen damit
// die lokale Hash-Liste nicht aus dem Tritt gerät.
const onDomainChange = useCallback(async () => {
await refreshDomains();
if (urlFilterActiveRef.current) {
const sync = await syncBlocklist();
console.log('[blocker] resync after domain change:', sync);
await refresh();
}
}, [refreshDomains, syncBlocklist, refresh]);
useDomainSubmissionRealtime(onDomainChange, true);
// Sheet-States
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [detailsOpen, setDetailsOpen] = useState(false);
const [explainerOpen, setExplainerOpen] = useState(false);
// Layer-Status (auf iOS): urlFilter + familyControls.
// AppDeletionLock=true bedeutet "locked in" → keine Switches mehr, nur Cooldown-Pfad.
const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
const lockedIn = appDeletionLockActive;
// Ref damit onDomainChange nicht neu rendert bei jedem urlFilter-Toggle
const urlFilterActiveRef = useRef(urlFilterActive);
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
// Auto-Sync wenn URL-Filter beim Page-Mount/-Resume schon aktiv ist und
// blocklist.bin leer/stale sein könnte. Dedupe via Ref damit wir nicht
// bei jedem Re-Render neu syncen.
const syncedOnceRef = useRef(false);
useEffect(() => {
if (!urlFilterActive) return;
if (syncedOnceRef.current) return;
syncedOnceRef.current = true;
syncBlocklist().then((res) => {
console.log('[blocker] auto-sync on mount:', res);
if (res.ok) refresh(); // Stats-Card neu rendern mit aktuellem Count
});
}, [urlFilterActive, syncBlocklist, refresh]);
// ─── Activate-Handler pro Layer ──────────────────────────────────────
async function handleActivateUrlFilter() {
try {
const result = await activateUrlFilter();
console.log('[blocker] activateUrlFilter:', result);
if (!result.enabled) {
Alert.alert(
t('blocker.activate_url_failed_title'),
result.error ?? t('blocker.activate_url_failed_msg'),
[
{ text: t('common.ok') },
{ text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() },
],
);
} else {
// Filter ist aktiv aber blocklist.bin ist initial leer — sofort syncen!
// Sonst zeigt iOS "Läuft" aber blockt nichts.
const sync = await syncBlocklist();
console.log('[blocker] post-activate sync:', sync);
if (sync.ok) {
// Stats-Card neu rendern mit dem frisch geschriebenen Count
await refresh();
} else {
Alert.alert(
t('blocker.sync_list_failed_title'),
sync.error ?? t('blocker.sync_list_failed_msg'),
);
}
}
return result;
} catch (e: any) {
console.error('[blocker] activateUrlFilter threw:', e);
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
return { enabled: false };
}
}
async function handleActivateFamilyControls() {
try {
const result = await activateFamilyControls();
console.log('[blocker] activateFamilyControls:', result);
if (!result.enabled) {
Alert.alert(
t('blocker.activate_app_lock_failed_title'),
result.error ?? t('blocker.activate_app_lock_failed_msg'),
);
}
} catch (e: any) {
console.error('[blocker] activateFamilyControls threw:', e);
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
}
return { enabled: false };
}
// ─── 3-Click Cooldown-Trigger ────────────────────────────────────────
function openDetails() {
setDetailsOpen(true);
}
function fromDetailsToExplainer() {
setDetailsOpen(false);
setTimeout(() => setExplainerOpen(true), 250);
}
function deflectToLyra() {
setDetailsOpen(false);
setTimeout(() => router.push('/lyra' as any), 250);
}
function deflectToBreathe() {
setExplainerOpen(false);
setTimeout(() => router.push('/urge' as any), 250);
}
async function handleStartCooldown(reason: string) {
await requestDeactivation(reason);
}
async function handleCancelCooldown() {
try {
await cancelDeactivation();
} catch (e: any) {
Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_cancel_failed'));
}
}
const bypassAlertShownRef = useRef(false);
useEffect(() => {
if (state?.phase !== 'recoveringFromBypass') {
bypassAlertShownRef.current = false;
return;
}
if (bypassAlertShownRef.current) return;
bypassAlertShownRef.current = true;
Alert.alert(
t('blocker.activate_app_lock_failed_title'),
t('blocker.layers_app_lock_warning'),
[{
text: t('common.ok'),
onPress: () => {
void handleActivateFamilyControls();
},
}],
{ cancelable: false },
);
}, [state?.phase, t]);
// ─── Render ──────────────────────────────────────────────────────────
return (
<View className="flex-1 bg-neutral-50">
<AppHeader />
{loading && !state ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color="#737373" />
</View>
) : state ? (
<>
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: tabBarHeight + 80, // platz für FAB + Tab-Bar
gap: 14,
}}
showsVerticalScrollIndicator={false}
>
{/* Locked-In Mode (FC aktiv) → NUR Schutz-Status + Cooldown-Pfad */}
{lockedIn ? (
<ProtectionLockedCard state={state} onPressSettings={openDetails} />
) : (
// FC nicht aktiv → User kann pro Layer einzeln togglen
<View style={{ gap: 10 }}>
<LayerSwitchCard
icon="globe-outline"
title={t('blocker.layers_url_filter_title')}
subtitle={
urlFilterActive
? t('blocker.layers_url_filter_subtitle_active')
: t('blocker.layers_url_filter_subtitle_inactive')
}
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
<LayerSwitchCard
icon="lock-closed-outline"
title={t('blocker.layers_app_lock_title')}
subtitle={
appDeletionLockActive
? t('blocker.layers_app_lock_subtitle_active')
: t('blocker.layers_app_lock_subtitle_inactive')
}
active={appDeletionLockActive}
onActivate={handleActivateFamilyControls}
warning={t('blocker.layers_app_lock_warning')}
/>
</View>
)}
{/* CooldownBanner — nur wenn Cooldown läuft */}
{state.cooldown.active && (
<CooldownBanner
remainingFormatted={cooldownRemainingFormatted}
onCancel={handleCancelCooldown}
/>
)}
{/* Domain Grid mit inline + Button neben SlotPill */}
<View style={{ marginTop: 8 }}>
<DomainGrid
domains={domains}
tier={tier}
onAdd={() => setAddSheetOpen(true)}
onSubmit={submitDomain}
onUpgradePro={() => Alert.alert(t('blocker.upgrade_alert_title'), t('blocker.upgrade_alert_desc'))}
/>
</View>
</ScrollView>
{/* Sheets */}
<AddDomainSheet
visible={addSheetOpen}
tier={tier}
onClose={() => {
setAddSheetOpen(false);
refreshDomains();
}}
onAdd={async (d) => {
const result = await addDomain(d);
if (result.ok) {
// Neue Custom-Domain → Filter muss aktualisierten Hash-Set kriegen
const sync = await syncBlocklist();
if (sync.ok) refresh(); // Stats-Card mit neuem Count refreshen
}
return result;
}}
/>
<ProtectionDetailsSheet
visible={detailsOpen}
state={state}
onClose={() => setDetailsOpen(false)}
onRequestDeactivation={fromDetailsToExplainer}
onTalkToLyra={deflectToLyra}
/>
<DeactivationExplainerSheet
visible={explainerOpen}
onClose={() => setExplainerOpen(false)}
onBreathe={deflectToBreathe}
onStartCooldown={handleStartCooldown}
/>
</>
) : null}
</View>
);
}

View File

@ -0,0 +1,410 @@
import { useState, useCallback } from 'react';
import {
View,
Text,
FlatList,
Pressable,
ActivityIndicator,
Image,
RefreshControl,
StyleSheet,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { AppHeader } from '../../components/AppHeader';
import { RoomCard, type Room } from '../../components/chat/RoomCard';
import { CreateRoomSheet } from '../../components/chat/CreateRoomSheet';
import { colors } from '../../lib/theme';
type DmConversation = {
partnerId: string;
partnerName: string;
partnerAvatar: string | null;
lastMessage: string;
lastMessageAt: string;
unreadCount: number;
isOwn: boolean;
};
function formatTime(ts: string, justNowLabel: string): string {
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60_000) return justNowLabel;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }) {
const { t } = useTranslation();
const hasUnread = conv.unreadCount > 0;
return (
<Pressable onPress={onPress} android_ripple={{ color: '#f5f5f5' }}>
{({ pressed }) => (
<View style={[styles.dmRow, { opacity: pressed ? 0.75 : 1 }]}>
<View style={styles.dmAvatar}>
{conv.partnerAvatar ? (
<Image source={{ uri: conv.partnerAvatar }} style={styles.dmAvatarImg} />
) : (
<Text style={styles.dmAvatarInitials}>
{conv.partnerName.slice(0, 2).toUpperCase()}
</Text>
)}
</View>
<View style={styles.dmInfo}>
<View style={styles.dmHeaderRow}>
<Text style={styles.dmName} numberOfLines={1}>
{conv.partnerName}
</Text>
<Text
style={[styles.dmTime, { color: hasUnread ? '#007AFF' : '#a3a3a3' }]}
>
{formatTime(conv.lastMessageAt, t('chat.just_now'))}
</Text>
</View>
<View style={styles.dmBottomRow}>
<Text
numberOfLines={1}
style={[
styles.dmLast,
{
fontFamily: hasUnread ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
color: hasUnread ? '#171717' : '#a3a3a3',
},
]}
>
{conv.isOwn ? t('chat.you') : ''}
{conv.lastMessage}
</Text>
{hasUnread && (
<View style={styles.unreadBadge}>
<Text style={styles.unreadBadgeText}>{conv.unreadCount}</Text>
</View>
)}
</View>
</View>
</View>
)}
</Pressable>
);
}
export default function ChatScreen() {
const { t } = useTranslation();
const router = useRouter();
const [tab, setTab] = useState<'groups' | 'direct'>('groups');
const [createOpen, setCreateOpen] = useState(false);
const {
data: rooms = [],
isLoading: loadingRooms,
isRefetching: refetchingRooms,
refetch: refetchRooms,
} = useQuery<Room[]>({
queryKey: ['chat-rooms'],
queryFn: () => apiFetch('/api/chat/rooms'),
staleTime: 30_000,
});
const {
data: convs = [],
isLoading: loadingDms,
isRefetching: refetchingDms,
refetch: refetchDms,
} = useQuery<DmConversation[]>({
queryKey: ['dm-conversations'],
queryFn: () => apiFetch('/api/chat/dm-conversations'),
staleTime: 30_000,
enabled: tab === 'direct',
});
const unreadDms = convs.reduce((s, c) => s + (c.unreadCount ?? 0), 0);
const openRoom = useCallback(
(roomId: string) => {
router.push(`/room?roomId=${roomId}`);
},
[router],
);
const openDm = useCallback(
(userId: string) => {
router.push(`/dm?userId=${userId}`);
},
[router],
);
return (
<View style={styles.container}>
<AppHeader />
{/* Header */}
<View style={styles.headerSection}>
<View style={styles.titleRow}>
<Text style={styles.title}>{t('chat.title')}</Text>
{tab === 'groups' && (
<Pressable
onPress={() => setCreateOpen(true)}
style={({ pressed }) => [styles.createBtn, { opacity: pressed ? 0.7 : 1 }]}
>
<Ionicons name="add" size={20} color="#fff" />
</Pressable>
)}
</View>
{/* Tabs */}
<View style={styles.tabs}>
<Pressable
onPress={() => setTab('groups')}
style={[styles.tab, tab === 'groups' && styles.tabActive]}
>
<Ionicons
name="people"
size={14}
color={tab === 'groups' ? '#007AFF' : '#737373'}
/>
<Text style={[styles.tabText, tab === 'groups' && styles.tabTextActive]}>
{t('chat.groups')}
</Text>
</Pressable>
<Pressable
onPress={() => setTab('direct')}
style={[styles.tab, tab === 'direct' && styles.tabActive]}
>
<Ionicons
name="chatbubbles"
size={14}
color={tab === 'direct' ? '#007AFF' : '#737373'}
/>
<Text style={[styles.tabText, tab === 'direct' && styles.tabTextActive]}>
{t('chat.direct')}
</Text>
{unreadDms > 0 && (
<View style={styles.tabBadge}>
<Text style={styles.tabBadgeText}>{unreadDms}</Text>
</View>
)}
</Pressable>
</View>
</View>
{tab === 'groups' ? (
<FlatList
data={loadingRooms ? [] : rooms}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={refetchingRooms}
onRefresh={refetchRooms}
tintColor={colors.brandOrange}
/>
}
ListEmptyComponent={
loadingRooms ? (
<View style={styles.emptyBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : (
<View style={styles.emptyBox}>
<Ionicons name="people-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_rooms')}</Text>
</View>
)
}
renderItem={({ item }) => <RoomCard room={item} onPress={() => openRoom(item.id)} />}
contentContainerStyle={{ paddingBottom: 100 }}
/>
) : (
<FlatList
data={loadingDms ? [] : convs}
keyExtractor={(item) => item.partnerId}
refreshControl={
<RefreshControl
refreshing={refetchingDms}
onRefresh={refetchDms}
tintColor={colors.brandOrange}
/>
}
ListEmptyComponent={
loadingDms ? (
<View style={styles.emptyBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : (
<View style={styles.emptyBox}>
<Ionicons name="chatbubble-ellipses-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
</View>
)
}
renderItem={({ item }) => <DmItem conv={item} onPress={() => openDm(item.partnerId)} />}
contentContainerStyle={{ paddingBottom: 100 }}
/>
)}
<CreateRoomSheet
visible={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(room) => {
refetchRooms();
openRoom(room.id);
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
headerSection: {
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 10,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: 22,
fontFamily: 'Nunito_800ExtraBold',
color: '#171717',
},
createBtn: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
},
tabs: {
flexDirection: 'row',
marginTop: 12,
backgroundColor: '#f5f5f5',
borderRadius: 10,
padding: 3,
},
tab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 8,
},
tabActive: {
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
},
tabText: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
marginLeft: 5,
},
tabTextActive: {
color: '#007AFF',
fontFamily: 'Nunito_700Bold',
},
tabBadge: {
minWidth: 16,
height: 16,
borderRadius: 8,
backgroundColor: '#007AFF',
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 5,
},
tabBadgeText: {
fontSize: 9,
fontFamily: 'Nunito_700Bold',
color: '#fff',
},
emptyBox: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
},
emptyText: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#a3a3a3',
marginTop: 12,
},
// DM row styles
dmRow: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 11,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
},
dmAvatar: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 10,
},
dmAvatarImg: { width: 42, height: 42 },
dmAvatarInitials: {
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: '#525252',
},
dmInfo: { flex: 1, minWidth: 0 },
dmHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
dmName: {
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: '#171717',
flexShrink: 1,
marginRight: 6,
},
dmTime: { fontSize: 11, fontFamily: 'Nunito_600SemiBold' },
dmBottomRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 2,
},
dmLast: { fontSize: 12, flex: 1 },
unreadBadge: {
minWidth: 20,
height: 20,
paddingHorizontal: 6,
borderRadius: 10,
backgroundColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 8,
},
unreadBadgeText: {
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: '#fff',
},
});

View File

@ -0,0 +1,24 @@
import { useCallback } from 'react';
import { View } from 'react-native';
import { useRouter, useFocusEffect } from 'expo-router';
/**
* Placeholder-Screen für den Coach-Tab.
* Beim Fokussieren wird sofort auf den Root-Stack `/coach` umgeleitet.
* - Coach-Tab bleibt in der Tab-Bar sichtbar
* - Tap auf Coach-Tab Root-Coach öffnet sich (über den Tabs)
* - Tab-Bar ist auf der Coach-Page automatisch versteckt
*/
export default function CoachTabRedirect() {
const router = useRouter();
useFocusEffect(
useCallback(() => {
// replace statt push: Back von /lyra geht damit zurück zum vorherigen Tab,
// nicht zurück auf diesen Placeholder (würde sonst infinite loop bilden)
router.replace('/lyra');
}, [router]),
);
return <View style={{ flex: 1, backgroundColor: '#ffffff' }} />;
}

View File

@ -0,0 +1,198 @@
import { useCallback, useState } from 'react';
import {
View,
Text,
ScrollView,
FlatList,
Pressable,
RefreshControl,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { AppHeader } from '../../components/AppHeader';
import { ComposeCard } from '../../components/ComposeCard';
import { PostCard } from '../../components/PostCard';
import { PostCardSkeleton } from '../../components/PostCardSkeleton';
import { PostCommentsSheet } from '../../components/PostCommentsSheet';
import { useCommunityStore, type CommunityCategory, type CommunityPost } from '../../stores/community';
import { useCommunityRealtime } from '../../hooks/useCommunityRealtime';
import { colors } from '../../lib/theme';
type FilterChip = {
value: CommunityCategory;
label: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
};
export default function HomeScreen() {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Granular selectors: subscribing to the whole store (incl. optimisticLikes)
// would re-render the screen — and thus the FlatList — on every like.
const activeCategory = useCommunityStore((s) => s.activeCategory);
const setCategory = useCommunityStore((s) => s.setCategory);
const FILTERS: FilterChip[] = [
{ value: 'all', label: t('community.cat_all'), icon: 'grid-outline' },
{ value: 'games', label: t('community.cat_games'), icon: 'trophy-outline' },
{ value: 'domain_vote', label: t('community.cat_domain'), icon: 'shield-outline' },
{ value: 'lyra', label: t('community.cat_lyra'), icon: 'sparkles-outline' },
{ value: 'rebreak', label: t('community.cat_rebreak'), icon: 'megaphone-outline' },
];
const [filterOpen, setFilterOpen] = useState(false);
const [activeCommentsPostId, setActiveCommentsPostId] = useState<string | null>(null);
const { data: posts = [], isLoading, isRefetching, refetch } = useQuery<CommunityPost[]>({
queryKey: ['community-posts', activeCategory],
queryFn: () => apiFetch(`/api/community/posts?category=${activeCategory}&limit=30`),
staleTime: 60_000,
});
// Realtime: live updates für Posts (likes/comments/neue Posts/domain-vote-Status)
useCommunityRealtime(true);
const toggleFilter = (value: CommunityCategory) => {
const next = activeCategory === value ? 'all' : value;
setCategory(next);
setFilterOpen(false);
};
// Stable callbacks — passed to memoized PostCards. Inline arrows would
// bust React.memo on every parent render (which also happens on every
// realtime patch since the posts array gets a new reference).
const openComments = useCallback((postId: string) => {
setActiveCommentsPostId(postId);
}, []);
const closeComments = useCallback(() => setActiveCommentsPostId(null), []);
const keyExtractor = useCallback((item: CommunityPost) => item.id, []);
const renderItem = useCallback(
({ item }: { item: CommunityPost }) => (
<PostCard post={item} onCommentPress={openComments} />
),
[openComments],
);
return (
<View className="flex-1 bg-neutral-50">
<AppHeader />
<FlatList
data={isLoading ? [] : posts}
keyExtractor={keyExtractor}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 12, paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
// Performance tuning — measured on Galaxy A50 (older mid-range):
// VirtualizedList warned with dt=2.26s for ~7k content. These props
// shrink the per-batch render cost and reclaim off-screen views.
removeClippedSubviews
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={7}
updateCellsBatchingPeriod={50}
refreshControl={
<RefreshControl
refreshing={isRefetching}
onRefresh={refetch}
tintColor={colors.brandOrange}
/>
}
ListHeaderComponent={
<View>
<ComposeCard onPosted={() => refetch()} />
{/* Filter toggle */}
<View className="mb-3">
<Pressable
onPress={() => setFilterOpen((o) => !o)}
className="flex-row items-center gap-1.5 self-start"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Ionicons
name={filterOpen ? 'close-outline' : 'options-outline'}
size={18}
color={activeCategory !== 'all' ? colors.brandOrange : '#737373'}
/>
{activeCategory !== 'all' && (
<Text className="text-xs text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{FILTERS.find((f) => f.value === activeCategory)?.label}
</Text>
)}
</Pressable>
{filterOpen && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="mt-2"
contentContainerStyle={{ gap: 8, paddingBottom: 4 }}
>
{FILTERS.map((f) => {
const active = activeCategory === f.value;
return (
<Pressable
key={f.value}
onPress={() => toggleFilter(f.value)}
className={`flex-row items-center gap-1.5 h-8 px-3 rounded-full border ${
active
? 'bg-rebreak-500 border-rebreak-500'
: 'bg-white border-neutral-200'
}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<Ionicons
name={f.icon}
size={13}
color={active ? '#fff' : '#737373'}
/>
<Text
className={`text-xs ${
active ? 'text-white' : 'text-neutral-500'
}`}
style={{ fontFamily: 'Nunito_600SemiBold' }}
>
{f.label}
</Text>
</Pressable>
);
})}
</ScrollView>
)}
</View>
{/* Skeleton */}
{isLoading && (
<View>
<PostCardSkeleton />
<PostCardSkeleton />
<PostCardSkeleton />
</View>
)}
</View>
}
ListEmptyComponent={
isLoading ? null : (
<View className="items-center py-16">
<Ionicons name="chatbubbles-outline" size={40} color="#d4d4d4" />
<Text className="text-sm text-neutral-400 mt-3 text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.no_posts')}
</Text>
</View>
)
}
renderItem={renderItem}
/>
<PostCommentsSheet
postId={activeCommentsPostId}
visible={activeCommentsPostId !== null}
onClose={closeComments}
/>
</View>
);
}

View File

@ -0,0 +1,235 @@
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
Text,
View,
} from 'react-native';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { AppHeader } from '../../components/AppHeader';
import { MailStatsRow } from '../../components/mail/MailStatsRow';
import { MailAccountCard } from '../../components/mail/MailAccountCard';
import { MailEmptyState } from '../../components/mail/MailEmptyState';
import { MailActivityLog } from '../../components/mail/MailActivityLog';
import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet';
import { SuccessAlert } from '../../components/SuccessAlert';
import { useMailStatus } from '../../hooks/useMailStatus';
import { useMailDisconnect } from '../../hooks/useMailDisconnect';
import { useUserPlan } from '../../hooks/useUserPlan';
export default function MailScreen() {
const { t } = useTranslation();
const tabBarHeight = useBottomTabBarHeight();
const { plan } = useUserPlan();
const { connected, accounts, totalBlocked, maxAccounts, loading, refresh } =
useMailStatus(plan);
const { disconnect, disconnecting } = useMailDisconnect();
const [sheetVisible, setSheetVisible] = useState(false);
const [successVisible, setSuccessVisible] = useState(false);
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
const [activityLogExpanded, setActivityLogExpanded] = useState(false);
const nextScanAt =
accounts
.map((a) => a.nextScanAt)
.filter((v): v is string => v !== null)
.sort()[0] ?? null;
const limitReached = accounts.length >= maxAccounts;
function handleAddPress() {
if (limitReached) {
Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc'));
return;
}
setSheetVisible(true);
}
async function handleDisconnect(id: string) {
setDisconnectingId(id);
await disconnect(id);
setDisconnectingId(null);
if (expandedAccount === id) setExpandedAccount(null);
refresh();
}
function handleConnectSuccess() {
setSuccessVisible(true);
refresh();
}
function toggleAccount(id: string) {
setExpandedAccount((prev) => (prev === id ? null : id));
}
if (loading) {
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<AppHeader />
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
</View>
);
}
return (
<View style={{ flex: 1, backgroundColor: '#fafafa' }}>
<AppHeader />
<ScrollView
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: tabBarHeight + 24,
}}
showsVerticalScrollIndicator={false}
>
{/* Stats card */}
{accounts.length > 0 && (
<View style={{ marginBottom: 14 }}>
<MailStatsRow
totalBlocked={totalBlocked}
accountCount={accounts.length}
isLegend={plan === 'legend'}
/>
</View>
)}
{/* Section header with prominent + button */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
paddingHorizontal: 2,
}}
>
<View style={{ flex: 1, marginRight: 10 }}>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
textTransform: 'uppercase',
letterSpacing: 0.8,
}}
>
{t('mail.section_accounts')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 2,
}}
>
{maxAccounts === Infinity
? t('mail.section_accounts_count_unlimited', { used: accounts.length })
: t('mail.section_accounts_count', {
used: accounts.length,
max: maxAccounts,
})}
</Text>
</View>
<Pressable
onPress={handleAddPress}
disabled={limitReached}
android_ripple={{ color: '#0066cc' }}
style={{
backgroundColor: limitReached ? '#e5e5e5' : '#007AFF',
borderRadius: 12,
opacity: limitReached ? 0.7 : 1,
shadowColor: '#007AFF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: limitReached ? 0 : 0.25,
shadowRadius: 8,
elevation: limitReached ? 0 : 4,
}}
accessibilityLabel={t('mail.add_account_a11y')}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 10,
}}
>
<Ionicons
name="add"
size={18}
color={limitReached ? '#737373' : '#fff'}
style={{ marginRight: 6 }}
/>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: limitReached ? '#737373' : '#fff',
}}
>
{t('mail.add_account')}
</Text>
</View>
</Pressable>
</View>
{/* Account cards or empty */}
{accounts.length === 0 ? (
<MailEmptyState onConnectPress={handleAddPress} />
) : (
<View>
{accounts.map((account, idx) => (
<View key={account.id} style={{ marginTop: idx === 0 ? 0 : 10 }}>
<MailAccountCard
account={account}
plan={plan}
expanded={expandedAccount === account.id}
onToggle={() => toggleAccount(account.id)}
onDisconnect={handleDisconnect}
onIntervalChanged={refresh}
onEditSuccess={handleConnectSuccess}
disconnecting={disconnectingId === account.id && disconnecting}
/>
</View>
))}
</View>
)}
{/* Activity log */}
{accounts.length > 0 && (
<View style={{ marginTop: 14 }}>
<MailActivityLog
expanded={activityLogExpanded}
onToggle={() => setActivityLogExpanded((p) => !p)}
/>
</View>
)}
</ScrollView>
<ConnectMailSheet
visible={sheetVisible}
onClose={() => setSheetVisible(false)}
onSuccess={handleConnectSuccess}
/>
<SuccessAlert
visible={successVisible}
title={t('mail.connect_success_title')}
message={t('mail.connect_success_message')}
onClose={() => setSuccessVisible(false)}
/>
</View>
);
}

View File

@ -0,0 +1,153 @@
import { useEffect } from 'react';
import { View, Text, FlatList, Pressable, RefreshControl } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { HeroShieldCheck } from '../../components/HeroShieldCheck';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '../../components/EmptyState';
import { useNotificationStore, type AppNotification } from '../../stores/notifications';
import { colors } from '../../lib/theme';
export default function NotificationsScreen() {
const router = useRouter();
const { t } = useTranslation();
const items = useNotificationStore((s) => s.items);
const loaded = useNotificationStore((s) => s.loaded);
const load = useNotificationStore((s) => s.load);
const markRead = useNotificationStore((s) => s.markRead);
const remove = useNotificationStore((s) => s.remove);
useEffect(() => {
load();
const tm = setTimeout(() => {
markRead();
}, 400);
return () => clearTimeout(tm);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<SafeAreaView className="flex-1 bg-white" edges={['top']}>
<View className="flex-row items-center gap-3 px-5 pt-3 pb-3 border-b border-neutral-200">
<Pressable
onPress={() => router.back()}
className="w-9 h-9 rounded-full bg-neutral-100 border border-neutral-200 items-center justify-center"
>
<Ionicons name="arrow-back" size={18} color="#737373" />
</Pressable>
<Text
className="text-neutral-900 text-lg flex-1"
style={{ fontFamily: 'Nunito_700Bold' }}
>
{t('notifications.title')}
</Text>
</View>
{items.length === 0 ? (
<EmptyState
icon="notifications-off-outline"
title={t('notifications.empty_title')}
subtitle={t('notifications.empty_subtitle')}
/>
) : (
<FlatList
data={items}
keyExtractor={(n) => n.id}
contentContainerStyle={{ paddingVertical: 8 }}
refreshControl={
<RefreshControl
refreshing={!loaded}
onRefresh={load}
tintColor={colors.brandOrange}
/>
}
renderItem={({ item }) => (
<NotificationRow
notif={item}
onPress={() => {
if (item.postId) {
router.push(`/?postId=${item.postId}` as never);
}
}}
onDelete={() => remove(item.id)}
/>
)}
/>
)}
</SafeAreaView>
);
}
function NotificationRow({
notif,
onPress,
onDelete,
}: {
notif: AppNotification;
onPress: () => void;
onDelete: () => void;
}) {
const isUnread = !notif.readAt;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
backgroundColor: isUnread ? '#fff7ed' : '#fff',
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#f5f5f5',
}}
>
{/* Pure-Icon — KEIN bg-Circle (User-Wunsch: kein extra Rand). */}
<View style={{ width: 36, alignItems: 'center', justifyContent: 'center', marginRight: 12 }}>
{notif.type === 'domain_accepted' ? (
<HeroShieldCheck size={22} color="#16a34a" />
) : (
<Ionicons name={iconForType(notif.type)} size={22} color="#d97706" />
)}
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
numberOfLines={1}
>
{notif.actorName}
</Text>
{notif.preview && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
marginTop: 2,
}}
numberOfLines={2}
>
{notif.preview}
</Text>
)}
</View>
<Pressable onPress={onDelete} hitSlop={8}>
<Ionicons name="close" size={16} color="#a3a3a3" />
</Pressable>
</View>
</Pressable>
);
}
function iconForType(type: string): React.ComponentProps<typeof Ionicons>['name'] {
if (type.includes('like')) return 'heart';
if (type.includes('comment')) return 'chatbubble';
if (type.includes('follow')) return 'person-add';
if (type.includes('domain')) return 'shield-checkmark';
return 'notifications';
}

View File

@ -0,0 +1,12 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
/>
);
}

View File

@ -0,0 +1,207 @@
import { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
const OTP_LENGTH = 6;
const OTP_INPUT_STYLE = {
fontSize: 20,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center' as const,
width: 48,
height: 56,
borderRadius: 12,
};
export default function ConfirmOtpScreen() {
const router = useRouter();
const { t } = useTranslation();
const params = useLocalSearchParams<{ email: string }>();
const email = decodeURIComponent(params.email ?? '');
const { verifyOtp, resendConfirmation } = useAuthStore();
const [digits, setDigits] = useState<string[]>(Array(OTP_LENGTH).fill(''));
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [resendCooldown, setResendCooldown] = useState(0);
const inputs = useRef<Array<TextInput | null>>([]);
useEffect(() => {
if (!email) {
router.replace('/signup');
}
}, [email]);
useEffect(() => {
if (resendCooldown <= 0) return;
const t = setInterval(() => {
setResendCooldown((c) => {
if (c <= 1) { clearInterval(t); return 0; }
return c - 1;
});
}, 1000);
return () => clearInterval(t);
}, [resendCooldown]);
const otp = digits.join('');
const handleDigit = (value: string, index: number) => {
if (value.length === OTP_LENGTH && /^\d+$/.test(value)) {
const next = value.split('');
setDigits(next);
inputs.current[OTP_LENGTH - 1]?.focus();
return;
}
const digit = value.replace(/\D/g, '').slice(-1);
const next = [...digits];
next[index] = digit;
setDigits(next);
if (digit && index < OTP_LENGTH - 1) {
inputs.current[index + 1]?.focus();
}
};
const handleKeyPress = (key: string, index: number) => {
if (key === 'Backspace' && !digits[index] && index > 0) {
inputs.current[index - 1]?.focus();
}
};
const verify = async () => {
if (otp.length < OTP_LENGTH || loading || success) return;
setError(null);
setLoading(true);
const res = await verifyOtp(email, otp);
setLoading(false);
if (res.error) {
setError(res.error);
setDigits(Array(OTP_LENGTH).fill(''));
inputs.current[0]?.focus();
return;
}
setSuccess(true);
router.replace('/(app)');
};
const resend = async () => {
if (resendCooldown > 0) return;
setError(null);
const res = await resendConfirmation(email);
if (res.error) {
setError(res.error);
return;
}
setResendCooldown(60);
};
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<View className="flex-1 px-6 justify-center">
{/* Header */}
<View className="items-center mb-8">
<View className="w-16 h-16 bg-rebreak-500/10 rounded-full items-center justify-center mb-4">
<Text className="text-3xl" style={{ fontFamily: 'Nunito_400Regular' }}>&#x2709;</Text>
</View>
<Text className="text-2xl text-neutral-900 text-center mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>
{t('auth.confirmEmailTitle')}
</Text>
<Text className="text-sm text-neutral-500 text-center leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.confirmEmailLine1')}{'\n'}
<Text className="text-neutral-900" style={{ fontFamily: 'Nunito_600SemiBold' }}>{email}</Text>
{t('auth.confirmEmailLine2') ? `\n${t('auth.confirmEmailLine2')}` : ''}
</Text>
</View>
{/* OTP Input */}
<View className="flex-row justify-center gap-3 mb-6">
{digits.map((digit, index) => (
<TextInput
key={index}
ref={(ref) => { inputs.current[index] = ref; }}
style={[
OTP_INPUT_STYLE,
{
backgroundColor: '#f5f5f5',
borderWidth: 2,
borderColor: digit ? '#f59e0b' : '#e5e5e5',
},
]}
value={digit}
onChangeText={(val) => handleDigit(val, index)}
onKeyPress={({ nativeEvent }) => handleKeyPress(nativeEvent.key, index)}
keyboardType="number-pad"
maxLength={OTP_LENGTH}
editable={!loading && !success}
selectTextOnFocus
/>
))}
</View>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-4">
<Text className="text-red-600 text-sm text-center" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
{success && (
<View className="bg-green-50 border border-green-200 rounded-xl px-4 py-3 mb-4">
<Text className="text-green-700 text-sm text-center" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.confirmed')}</Text>
</View>
)}
<Pressable
onPress={verify}
disabled={otp.length < OTP_LENGTH || loading || success}
className="bg-rebreak-500 rounded-xl items-center mb-4 active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.confirmBtn')}</Text>
)}
</Pressable>
<Pressable
onPress={resend}
disabled={resendCooldown > 0 || loading}
className="py-3 items-center"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.noCode')}{' '}
<Text className={resendCooldown > 0 ? 'text-neutral-400' : 'text-rebreak-500'} style={{ fontFamily: 'Nunito_600SemiBold' }}>
{resendCooldown > 0 ? t('auth.resendCooldown', { seconds: resendCooldown }) : t('auth.resend')}
</Text>
</Text>
</Pressable>
<Pressable
onPress={() => router.back()}
className="py-3 items-center mt-2"
>
<Text className="text-neutral-400 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.backToSignup')}</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { View, Text, Pressable, ActivityIndicator } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { supabase } from '../../lib/supabase';
import { useAuthStore } from '../../stores/auth';
/**
* Deep-link landing screen for email-confirmation OAuth callbacks.
* Invoked when the system opens rebreak://auth/confirm?access_token=...&refresh_token=...
*
* Strategy: extract tokens from URL params (Expo Router surfaces them),
* call setSession, then route to app. If params are missing, poll getSession()
* (covers the case where supabase-js processed the hash before we got here).
*/
export default function ConfirmScreen() {
const router = useRouter();
const { t } = useTranslation();
const params = useLocalSearchParams<{
access_token?: string;
refresh_token?: string;
error?: string;
error_description?: string;
}>();
const [statusMsg, setStatusMsg] = useState(t('auth.confirming'));
const [errorMsg, setErrorMsg] = useState<string | null>(null);
useEffect(() => {
handleConfirm();
}, []);
async function handleConfirm() {
if (params.error) {
setErrorMsg(params.error_description ?? params.error ?? t('auth.confirmFailed'));
return;
}
if (params.access_token && params.refresh_token) {
const { data, error } = await supabase.auth.setSession({
access_token: params.access_token,
refresh_token: params.refresh_token,
});
if (error) {
setErrorMsg(error.message);
return;
}
useAuthStore.setState({ session: data.session, user: data.session?.user ?? null });
setStatusMsg(t('auth.confirmSuccess'));
router.replace('/(app)');
return;
}
// Fallback: poll up to 20s for a session (supabase-js may have processed hash already)
const maxWait = 20_000;
const interval = 500;
const start = Date.now();
while (Date.now() - start < maxWait) {
const { data } = await supabase.auth.getSession();
if (data.session) {
useAuthStore.setState({ session: data.session, user: data.session.user });
setStatusMsg(t('auth.confirmSuccess'));
router.replace('/(app)');
return;
}
await new Promise((r) => setTimeout(r, interval));
}
setErrorMsg(t('auth.confirmTimeout'));
}
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center px-6">
{!errorMsg ? (
<>
<ActivityIndicator color="#f59e0b" size="large" className="mb-4" />
<Text className="text-neutral-500 text-sm text-center" style={{ fontFamily: 'Nunito_400Regular' }}>{statusMsg}</Text>
</>
) : (
<>
<Text className="text-red-600 text-sm text-center mb-6" style={{ fontFamily: 'Nunito_400Regular' }}>{errorMsg}</Text>
<Pressable
onPress={() => router.replace('/signin')}
className="bg-neutral-100 border border-neutral-200 px-6 py-3 rounded-xl"
>
<Text className="text-neutral-800 text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.backToLoginPlain')}</Text>
</Pressable>
</>
)}
</View>
</SafeAreaView>
);
}

View File

@ -0,0 +1,44 @@
import { View, Text, Pressable } from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
/**
* Shown when the backend returns a device-limit error (HTTP 403 with device_limit_reached).
* Phase 2: static info + redirect to sign-in.
* Phase 4+: integrate with device-management store to show active devices + revoke option.
*/
export default function DeviceLimitScreen() {
const router = useRouter();
const { t } = useTranslation();
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center px-6 text-center">
<Text className="text-5xl mb-4" style={{ fontFamily: 'Nunito_400Regular' }}>&#x1F4F1;</Text>
<Text className="text-xl text-neutral-900 mb-3 text-center" style={{ fontFamily: 'Nunito_700Bold' }}>
{t('auth.deviceLimitTitle')}
</Text>
<Text className="text-sm text-neutral-500 text-center leading-6 max-w-xs mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.deviceLimitDesc')}
</Text>
{/* TODO Phase 4: device management — list active devices + revoke button */}
<Pressable
onPress={() => router.replace('/signin')}
className="bg-rebreak-500 px-8 py-4 rounded-xl active:opacity-80"
>
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.toLogin')}</Text>
</Pressable>
<Pressable
onPress={() => router.push('/signin')}
className="py-3 mt-2"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.deviceLimitUpgrade')}</Text>
</Pressable>
</View>
</SafeAreaView>
);
}

View File

@ -0,0 +1,113 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: '#0a0a0a',
fontFamily: 'Nunito_400Regular',
} as const;
export default function ForgotPasswordScreen() {
const router = useRouter();
const { t } = useTranslation();
const { resetPasswordForEmail } = useAuthStore();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async () => {
if (!email.trim()) return;
setError(null);
setLoading(true);
const res = await resetPasswordForEmail(email.trim());
setLoading(false);
if (res.error) {
setError(res.error);
return;
}
setSent(true);
};
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<View className="flex-1 px-6 justify-center">
<Text className="text-3xl text-neutral-900 mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.resetPasswordTitle')}</Text>
<Text className="text-base text-neutral-500 mb-8 leading-6" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.resetPasswordSubtitle')}
</Text>
{!sent ? (
<>
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.emailPlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
autoFocus
/>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-3">
<Text className="text-red-600 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
<Pressable
onPress={onSubmit}
disabled={loading || !email.trim()}
className="bg-rebreak-500 rounded-xl items-center mt-1 active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{loading ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.resetPasswordSend')}</Text>
)}
</Pressable>
</>
) : (
<View className="bg-green-50 border border-green-200 rounded-xl px-5 py-6 mb-4">
<Text className="text-green-700 text-base mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.resetPasswordSent')}</Text>
<Text className="text-green-600 text-sm leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.resetPasswordSentDescPrefix')}<Text style={{ fontFamily: 'Nunito_600SemiBold' }}>{email}</Text>{t('auth.resetPasswordSentDescSuffix')}
</Text>
</View>
)}
<Pressable
onPress={() => router.back()}
className="py-4 items-center mt-2"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.backToLogin')}</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,199 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
ScrollView,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
function GoogleIcon() {
return (
<Svg width={20} height={20} 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={20} height={20} 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>
);
}
type OAuthProvider = 'google' | 'apple' | null;
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: '#0a0a0a',
fontFamily: 'Nunito_400Regular',
} as const;
export default function SignInScreen() {
const router = useRouter();
const { t } = useTranslation();
const { signInWithPassword, signInWithOAuth } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [oauthLoading, setOauthLoading] = useState<OAuthProvider>(null);
const onSubmit = async () => {
if (!email.trim() || !password) return;
setError(null);
setSubmitting(true);
const res = await signInWithPassword(email.trim(), password);
setSubmitting(false);
if (res.error) {
setError(res.error);
return;
}
router.replace('/(app)');
};
const onOAuth = async (provider: 'google' | 'apple') => {
setError(null);
setOauthLoading(provider);
const res = await signInWithOAuth(provider);
setOauthLoading(null);
if (res.error) {
setError(res.error);
return;
}
router.replace('/(app)');
};
const isLoading = submitting || oauthLoading !== null;
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1, justifyContent: 'center' }}
keyboardShouldPersistTaps="handled"
className="px-6"
>
<Text className="text-3xl text-neutral-900 mb-2" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.welcomeBack')}</Text>
<Text className="text-base text-neutral-500 mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.signinSubtitle')}
</Text>
{/* OAuth Buttons */}
<Pressable
onPress={() => onOAuth('google')}
disabled={isLoading}
className="flex-row items-center justify-center gap-3 bg-white border border-neutral-200 rounded-xl mb-3 active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 14 }}
>
{oauthLoading === 'google' ? (
<ActivityIndicator color="#0a0a0a" size="small" />
) : (
<GoogleIcon />
)}
<Text className="text-neutral-900 text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.googleSignin')}</Text>
</Pressable>
<Pressable
onPress={() => onOAuth('apple')}
disabled={isLoading}
className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 14 }}
>
{oauthLoading === 'apple' ? (
<ActivityIndicator color="white" size="small" />
) : (
<AppleIcon />
)}
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.appleSignin')}</Text>
</Pressable>
{/* Divider */}
<View className="flex-row items-center mb-6">
<View className="flex-1 h-px bg-neutral-200" />
<Text className="text-neutral-400 text-xs mx-3" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.orWithEmail')}</Text>
<View className="flex-1 h-px bg-neutral-200" />
</View>
{/* Email + Password */}
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.emailPlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
className="bg-neutral-100 rounded-xl mb-1"
style={INPUT_STYLE}
placeholder={t('auth.passwordPlaceholder')}
placeholderTextColor="#a3a3a3"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
/>
<Pressable
onPress={() => router.push('/forgot-password')}
className="self-end py-2 mb-4"
>
<Text className="text-rebreak-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.forgotPassword')}</Text>
</Pressable>
{error && (
<Text className="text-red-500 text-sm mb-3" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
)}
<Pressable
onPress={onSubmit}
disabled={isLoading || !email || !password}
className="bg-rebreak-500 rounded-xl items-center mt-1 active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{submitting ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signin')}</Text>
)}
</Pressable>
<Pressable
onPress={() => router.push('/signup')}
className="py-4 items-center mt-2"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.noAccount')}{' '}
<Text className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signup')}</Text>
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,301 @@
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
ScrollView,
Image,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
import { HERO_AVATARS, getAvatarUrl } from '../../lib/avatars';
function GoogleIcon() {
return (
<Svg width={20} height={20} 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={20} height={20} 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>
);
}
type OAuthProvider = 'google' | 'apple' | null;
const INPUT_STYLE = {
fontSize: 16,
lineHeight: 22,
paddingVertical: 14,
paddingHorizontal: 16,
color: '#0a0a0a',
fontFamily: 'Nunito_400Regular',
} as const;
export default function SignUpScreen() {
const router = useRouter();
const { t } = useTranslation();
const { signUp, signInWithOAuth } = useAuthStore();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [nickname, setNickname] = useState('');
const [avatarId, setAvatarId] = useState('spider');
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [oauthLoading, setOauthLoading] = useState<OAuthProvider>(null);
const isLoading = submitting || oauthLoading !== null;
const onOAuth = async (provider: 'google' | 'apple') => {
setError(null);
setOauthLoading(provider);
const res = await signInWithOAuth(provider);
setOauthLoading(null);
if (res.error) {
setError(res.error);
return;
}
router.replace('/(app)');
};
const onSubmit = async () => {
if (!email.trim() || !password || !nickname.trim()) {
setError(t('auth.fillRequired'));
return;
}
if (password.length < 8) {
setError(t('auth.passwordMin8'));
return;
}
if (!termsAccepted) {
setError(t('auth.pleaseAcceptTerms'));
return;
}
setError(null);
setSubmitting(true);
const res = await signUp(email.trim(), password, {
username: nickname.trim(),
firstName: firstName.trim() || undefined,
lastName: lastName.trim() || undefined,
avatarId,
avatarUrl: getAvatarUrl(avatarId),
});
setSubmitting(false);
if (res.error) {
setError(res.error);
return;
}
router.push({ pathname: '/confirm-otp', params: { email: email.trim() } });
};
return (
<SafeAreaView className="flex-1 bg-white">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
className="flex-1"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1, paddingBottom: 32 }}
keyboardShouldPersistTaps="handled"
className="px-6 pt-8"
>
<Text className="text-3xl text-neutral-900 mb-1" style={{ fontFamily: 'Nunito_700Bold' }}>{t('auth.signupTitle')}</Text>
<Text className="text-base text-neutral-500 mb-8" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.signupSubtitle')}
</Text>
{/* OAuth Buttons */}
<Pressable
onPress={() => onOAuth('google')}
disabled={isLoading}
className="flex-row items-center justify-center gap-3 bg-white border border-neutral-200 rounded-xl mb-3 active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 14 }}
>
{oauthLoading === 'google' ? (
<ActivityIndicator color="#0a0a0a" size="small" />
) : (
<GoogleIcon />
)}
<Text className="text-neutral-900 text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.googleSignup')}</Text>
</Pressable>
<Pressable
onPress={() => onOAuth('apple')}
disabled={isLoading}
className="flex-row items-center justify-center gap-3 bg-neutral-900 rounded-xl mb-6 active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 14 }}
>
{oauthLoading === 'apple' ? (
<ActivityIndicator color="white" size="small" />
) : (
<AppleIcon />
)}
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.appleSignup')}</Text>
</Pressable>
{/* Divider */}
<View className="flex-row items-center mb-6">
<View className="flex-1 h-px bg-neutral-200" />
<Text className="text-neutral-400 text-xs mx-3" style={{ fontFamily: 'Nunito_400Regular' }}>{t('auth.orWithEmail')}</Text>
<View className="flex-1 h-px bg-neutral-200" />
</View>
{error && (
<View className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-4">
<Text className="text-red-600 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>{error}</Text>
</View>
)}
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.emailRequired')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
className="bg-neutral-100 rounded-xl mb-3"
style={INPUT_STYLE}
placeholder={t('auth.passwordRequired')}
placeholderTextColor="#a3a3a3"
secureTextEntry
autoComplete="new-password"
value={password}
onChangeText={setPassword}
/>
<View className="flex-row gap-3 mb-3">
<TextInput
className="flex-1 bg-neutral-100 rounded-xl"
style={INPUT_STYLE}
placeholder={t('auth.firstName')}
placeholderTextColor="#a3a3a3"
autoComplete="given-name"
value={firstName}
onChangeText={setFirstName}
/>
<TextInput
className="flex-1 bg-neutral-100 rounded-xl"
style={INPUT_STYLE}
placeholder={t('auth.lastName')}
placeholderTextColor="#a3a3a3"
autoComplete="family-name"
value={lastName}
onChangeText={setLastName}
/>
</View>
<TextInput
className="bg-neutral-100 rounded-xl mb-6"
style={INPUT_STYLE}
placeholder={t('auth.nicknamePlaceholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoComplete="username"
value={nickname}
onChangeText={setNickname}
/>
{/* Avatar Picker */}
<Text className="text-sm text-neutral-700 mb-3" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.chooseAvatar')}</Text>
<View className="flex-row flex-wrap gap-3 mb-6">
{HERO_AVATARS.map((avatar) => {
const selected = avatar.id === avatarId;
return (
<Pressable
key={avatar.id}
onPress={() => setAvatarId(avatar.id)}
disabled={isLoading}
className={`rounded-full ${selected ? 'opacity-100' : 'opacity-40'}`}
>
<Image
source={{ uri: avatar.url }}
className={`w-14 h-14 rounded-full border-2 ${selected ? avatar.color : 'border-transparent'}`}
style={{ width: 56, height: 56, borderRadius: 28 }}
/>
</Pressable>
);
})}
</View>
{/* Privacy notice */}
<View className="flex-row gap-3 bg-neutral-50 border border-neutral-200 rounded-xl p-4 mb-4">
<Text className="text-rebreak-500 text-base mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>&#x1F6E1;</Text>
<Text className="flex-1 text-xs text-neutral-500 leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.privacyNotice')}
</Text>
</View>
{/* Terms Checkbox */}
<Pressable
onPress={() => setTermsAccepted(!termsAccepted)}
disabled={isLoading}
className="flex-row items-start gap-3 mb-6"
>
<View
className={`w-5 h-5 rounded border-2 mt-0.5 items-center justify-center ${
termsAccepted ? 'bg-rebreak-500 border-rebreak-500' : 'border-neutral-300'
}`}
>
{termsAccepted && (
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}></Text>
)}
</View>
<Text className="flex-1 text-sm text-neutral-500 leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.acceptTerms')}{' '}
<Text className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.termsLink')}</Text>
{t('auth.acceptTermsSuffix')}
</Text>
</Pressable>
<Pressable
onPress={onSubmit}
disabled={isLoading || !email || !password || !nickname || !termsAccepted}
className="bg-rebreak-500 rounded-xl items-center active:opacity-80 disabled:opacity-40"
style={{ paddingVertical: 16 }}
>
{submitting ? (
<ActivityIndicator color="white" size="small" />
) : (
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signupTitle')}</Text>
)}
</Pressable>
<Pressable
onPress={() => router.push('/signin')}
className="py-4 items-center mt-2"
>
<Text className="text-neutral-500 text-sm" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('auth.alreadyRegistered')}{' '}
<Text className="text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('auth.signin')}</Text>
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,124 @@
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
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 * as SplashScreen from 'expo-splash-screen';
import {
useFonts,
Nunito_400Regular,
Nunito_600SemiBold,
Nunito_700Bold,
Nunito_800ExtraBold,
} from '@expo-google-fonts/nunito';
import { useAuthStore } from '../stores/auth';
import { BrandSplash } from '../components/BrandSplash';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
SplashScreen.preventAutoHideAsync();
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 1000 * 60,
},
},
});
function RootLayoutInner() {
const { loading, init } = useAuthStore();
const [fontsLoaded] = useFonts({
Nunito_400Regular,
Nunito_600SemiBold,
Nunito_700Bold,
Nunito_800ExtraBold,
});
useEffect(() => {
init();
}, []);
useEffect(() => {
if (fontsLoaded && !loading) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, loading]);
if (!fontsLoaded || loading) {
return <BrandSplash />;
}
return (
<>
<StatusBar style="dark" />
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
contentStyle: { backgroundColor: '#ffffff' },
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
<Stack.Screen
name="lyra"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="urge"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen
name="dm"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="settings"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
</Stack>
</>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<QueryClientProvider client={queryClient}>
<SafeAreaProvider>
<RootLayoutInner />
</SafeAreaProvider>
</QueryClientProvider>
</GestureHandlerRootView>
);
}

View File

@ -0,0 +1,57 @@
// Deep-Link Bridge für OAuth-Callback (Google/Apple).
//
// Hintergrund: nach erfolgreichem OAuth-Login redirected Supabase zu
// `rebreak://auth/callback#access_token=...`. Auf iOS schluckt
// `WebBrowser.openAuthSessionAsync` den Deep-Link bevor expo-router ihn sieht.
// Auf Android öffnet das System die App via Deep-Link → expo-router routet
// `/auth/callback` BEVOR openAuthSessionAsync's Listener feuert → 404.
//
// Diese Bridge-Page fängt das ab: zeigt einen Loader, extrahiert Tokens als
// Fallback (falls openAuthSessionAsync den Hash nicht selbst parst), und
// navigiert nach (app). signin.tsx macht zusätzlich router.replace('/(app)')
// nach openAuthSessionAsync resolve — diese Bridge ist nur für den Android-
// 404-Flash da.
import { useEffect } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { supabase } from '../../lib/supabase';
import { colors } from '../../lib/theme';
export default function AuthCallback() {
const router = useRouter();
const params = useLocalSearchParams<{ access_token?: string; refresh_token?: string }>();
useEffect(() => {
let cancelled = false;
(async () => {
const accessToken = typeof params.access_token === 'string' ? params.access_token : undefined;
const refreshToken = typeof params.refresh_token === 'string' ? params.refresh_token : undefined;
if (accessToken && refreshToken) {
try {
await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
} catch (err) {
console.warn('[auth-callback] setSession failed:', err);
}
}
// Kurzer Delay → onAuthStateChange propagiert die Session in den Store.
if (!cancelled) {
setTimeout(() => {
router.replace('/(app)' as never);
}, 60);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: '#ffffff' }}>
<ActivityIndicator size="large" color={colors.brandOrange} />
</View>
);
}

View File

@ -0,0 +1,365 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
Pressable,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Image,
StyleSheet,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { useDmRealtime } from '../hooks/useChatRealtime';
import { colors } from '../lib/theme';
type DmHistoryResponse = {
partner: {
id: string;
nickname: string;
username?: string;
avatar?: string | null;
};
messages: Array<{
id: string;
content: string;
createdAt: string;
isOwn: boolean;
readAt: string | null;
senderId?: string;
receiverId?: string;
likesCount?: number;
likedByMe?: boolean;
attachmentUrl?: string | null;
attachmentType?: string | null;
attachmentName?: string | null;
replyTo?: any;
}>;
};
const GROUP_GAP_MS = 5 * 60 * 1000;
export default function DmScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
const { userId } = useLocalSearchParams<{ userId: string }>();
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
null,
);
const [sending, setSending] = useState(false);
// Lade meine User-ID
useEffect(() => {
supabase.auth.getSession().then(({ data }) => {
setMyUserId(data.session?.user.id);
});
}, []);
// Lade DM-History
const { isLoading } = useQuery({
queryKey: ['dm-history', userId],
queryFn: async () => {
console.log('[dm] fetching history for partner', userId, 'me', myUserId);
try {
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
setPartner(data.partner);
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
id: m.id,
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
nickname: m.isOwn ? 'Du' : data.partner?.nickname ?? '?',
avatar: m.isOwn ? null : data.partner?.avatar ?? null,
content: m.content,
replyTo: m.replyTo
? {
id: m.replyTo.id,
userId: m.replyTo.senderId,
nickname:
m.replyTo.senderId === myUserId ? 'Du' : data.partner?.nickname ?? '?',
content: m.replyTo.content?.slice(0, 100) ?? '',
attachmentType: m.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: m.attachmentUrl ?? null,
attachmentType: m.attachmentType ?? null,
attachmentName: m.attachmentName ?? null,
likesCount: m.likesCount ?? 0,
likedByMe: m.likedByMe ?? false,
createdAt: m.createdAt,
isOwn: m.isOwn,
readAt: m.readAt,
}));
setMessages(msgs);
return data;
} catch (err: any) {
console.error('[dm] history fetch failed:', err?.message ?? err);
throw err;
}
},
enabled: !!userId && !!myUserId,
});
// Realtime: neue DMs vom Partner
const onDmInsert = useCallback(
(row: any) => {
if (row.receiver_id !== myUserId) return;
setMessages((prev) => {
if (prev.some((m) => m.id === row.id)) return prev;
return [
...prev,
{
id: row.id,
userId: row.sender_id,
nickname: partner?.nickname ?? '?',
avatar: partner?.avatar ?? null,
content: row.content ?? '',
replyTo: null,
attachmentUrl: row.attachment_url ?? null,
attachmentType: row.attachment_type ?? null,
attachmentName: row.attachment_name ?? null,
likesCount: row.likes_count ?? 0,
likedByMe: false,
createdAt: row.created_at,
isOwn: false,
readAt: null,
},
];
});
},
[myUserId, partner],
);
useDmRealtime(userId, onDmInsert, !!myUserId);
// Auto-Scroll bei neuen Messages
useEffect(() => {
if (messages.length > 0) {
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true }));
}
}, [messages.length]);
async function handleSend(payload: SendPayload) {
if (sending) return;
setSending(true);
try {
const newMsg = await apiFetch<any>('/api/chat/dm', {
method: 'POST',
body: { receiverId: userId, ...payload },
});
setMessages((prev) => [
...prev,
{
id: newMsg.id,
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content: newMsg.content,
replyTo: newMsg.replyTo
? {
id: newMsg.replyTo.id,
userId: newMsg.replyTo.senderId,
nickname:
newMsg.replyTo.senderId === myUserId ? 'Du' : partner?.nickname ?? '?',
content: newMsg.replyTo.content?.slice(0, 100) ?? '',
attachmentType: newMsg.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: newMsg.attachmentUrl,
attachmentType: newMsg.attachmentType,
attachmentName: newMsg.attachmentName,
likesCount: newMsg.likesCount ?? 0,
likedByMe: false,
createdAt: newMsg.createdAt,
isOwn: true,
readAt: null,
},
]);
setReplyTo(null);
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
} catch (err) {
console.error('DM send failed:', err);
} finally {
setSending(false);
}
}
async function toggleLike(msg: ChatMsg) {
try {
const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', {
method: 'POST',
body: { messageId: msg.id, type: 'dm' },
});
setMessages((prev) =>
prev.map((m) =>
m.id === msg.id
? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) }
: m,
),
);
} catch {}
}
function startReply(msg: ChatMsg) {
setReplyTo({
id: msg.id,
nickname: msg.nickname ?? '?',
content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''),
});
}
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
if (!a || !b) return false;
if (a.userId !== b.userId) return false;
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
</Pressable>
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
{partner?.avatar ? (
<Image source={{ uri: partner.avatar }} style={styles.headerAvatarImg} />
) : (
<Text style={styles.headerAvatarInitials}>
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
</Text>
)}
</View>
<Text style={styles.headerName} numberOfLines={1}>
{partner?.nickname ?? '…'}
</Text>
</View>
<View style={{ width: 36 }} />
</View>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={0}
>
{isLoading && messages.length === 0 ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : messages.length === 0 ? (
<View style={styles.loadingBox}>
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
</View>
) : (
<FlatList
ref={flatRef}
data={messages}
style={{ flex: 1 }}
renderItem={({ item, index }) => (
<ChatBubble
msg={item}
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
isLastInGroup={!sameAuthor(item, messages[index + 1])}
onReply={startReply}
onLike={toggleLike}
onOpenImage={() => {}}
/>
)}
keyExtractor={(m) => m.id}
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
)}
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<ChatInput
replyTo={replyTo}
sending={sending}
onSend={handleSend}
onCancelReply={() => setReplyTo(null)}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
backBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 8,
},
headerAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 8,
},
headerAvatarImg: { width: 32, height: 32 },
headerAvatarInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#171717',
flexShrink: 1,
},
loadingBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#a3a3a3',
marginTop: 12,
},
});

View File

@ -0,0 +1,31 @@
import { View, Text, Pressable } from 'react-native';
import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
export default function HomeScreen() {
const router = useRouter();
const { t } = useTranslation();
return (
<SafeAreaView className="flex-1 bg-white">
<View className="flex-1 items-center justify-center px-6">
<Text className="text-4xl text-neutral-900 mb-3" style={{ fontFamily: 'Nunito_700Bold' }}>{t('landing.appName')}</Text>
<Text className="text-base text-neutral-500 text-center mb-12" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('landing.tagline')}
</Text>
<Pressable
onPress={() => router.push('/signin')}
className="bg-rebreak-500 px-8 py-4 rounded-full active:opacity-80"
>
<Text className="text-white text-base" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('landing.start')}</Text>
</Pressable>
<Text className="text-xs text-neutral-400 mt-8" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('landing.version')}
</Text>
</View>
</SafeAreaView>
);
}

View File

@ -0,0 +1,976 @@
import {
useRef,
useState,
useEffect,
useCallback,
} from 'react';
import {
View,
Text,
TextInput,
FlatList,
Pressable,
Platform,
Animated,
Keyboard,
KeyboardAvoidingView,
StyleSheet,
ActivityIndicator,
NativeSyntheticEvent,
NativeScrollEvent,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Audio } from 'expo-av';
import * as FileSystem from 'expo-file-system';
import Constants from 'expo-constants';
import { useTranslation } from 'react-i18next';
import { RiveAvatar, type Emotion } from '../components/RiveAvatar';
import { useCoachStore, type Message } from '../stores/coach';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { colors } from '../lib/theme';
const EMPATHY_RE = /schwer|rückfall|traurig|schlimm|hoffnungslos|hilf|verloren|scham|schuld|verzweifelt/i;
const HAPPY_RE = /toll|super|geschafft|stark|glückwunsch|stolz|fantastisch|weiter so|prima|gut gemacht/i;
function detectEmotion(text: string): Emotion {
if (HAPPY_RE.test(text)) return 'happy';
if (EMPATHY_RE.test(text)) return 'empathy';
return 'idle';
}
function formatDuration(s: number): string {
const m = Math.floor(s / 60);
const sec = (s % 60).toString().padStart(2, '0');
return `${m}:${sec}`;
}
function formatTimestamp(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
// Standard-Spinner — kein zweiter Rive-Avatar (der ist bereits im topBar oben).
function LoadingPulse() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<ActivityIndicator size="large" color={colors.textMuted} />
</View>
);
}
// ── Thinking dots ─────────────────────────────────────────────────────────────
function ThinkingDots() {
const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current;
useEffect(() => {
const animations = anim.map((a, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 160),
Animated.timing(a, { toValue: 1, duration: 300, useNativeDriver: true }),
Animated.timing(a, { toValue: 0, duration: 300, useNativeDriver: true }),
])
)
);
animations.forEach((a) => a.start());
return () => animations.forEach((a) => a.stop());
}, []);
return (
<View style={styles.thinkingRow}>
{anim.map((a, i) => (
<Animated.View
key={i}
style={[
styles.thinkingDot,
{ transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] },
]}
/>
))}
</View>
);
}
// ── Voice bars ────────────────────────────────────────────────────────────────
function VoiceBars({ count, baseColor }: { count: number; baseColor: string }) {
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(4))).current;
useEffect(() => {
const animations = anims.map((a, i) =>
Animated.loop(
Animated.sequence([
Animated.timing(a, { toValue: 4 + Math.random() * 14, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
Animated.timing(a, { toValue: 4, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
])
)
);
animations.forEach((a) => a.start());
return () => animations.forEach((a) => a.stop());
}, []);
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, height: 20 }}>
{anims.map((a, i) => (
<Animated.View
key={i}
style={{ width: 2.5, height: a, borderRadius: 2, backgroundColor: baseColor, opacity: 0.75 }}
/>
))}
</View>
);
}
// ── Message row (Insta-DM style) ──────────────────────────────────────────────
type MessageWithMeta = Message & { timestamp: Date };
function MessageRow({
item,
t,
}: {
item: MessageWithMeta;
t: (key: string) => string;
}) {
const isUser = item.role === 'user';
return (
<View style={[styles.msgRow, isUser ? styles.msgRowUser : styles.msgRowAssistant]}>
<View style={[styles.bubbleCol, isUser ? styles.bubbleColUser : styles.bubbleColAssistant]}>
<View style={[styles.bubble, isUser ? styles.bubbleUser : styles.bubbleAssistant]}>
<Text style={[styles.bubbleText, isUser ? styles.bubbleTextUser : styles.bubbleTextAssistant]}>
{item.content}
</Text>
</View>
<View style={[styles.metaRow, isUser ? styles.metaRowUser : styles.metaRowAssistant]}>
{item.feedbackSaved && (
<>
<Ionicons name="checkmark-circle" size={11} color="#16a34a" />
<Text style={styles.feedbackText}>{t('coach.feedback_saved')}</Text>
</>
)}
<Text style={styles.timestampText}>{formatTimestamp(item.timestamp)}</Text>
</View>
</View>
</View>
);
}
// ── Main screen ───────────────────────────────────────────────────────────────
export default function CoachScreen() {
const { t, i18n } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const flatRef = useRef<FlatList>(null);
// Reaktive Slices — nur was UI-relevant ist (Re-Render bei diesen).
const messages = useCoachStore((s) => s.messages);
const thinking = useCoachStore((s) => s.thinking);
// Actions sind stable — keine Re-Renders bei Bezug.
const loadHistory = useCoachStore((s) => s.loadHistory);
const clearHistory = useCoachStore((s) => s.clearHistory);
const sendMessage = useCoachStore((s) => s.sendMessage);
const pushMessage = useCoachStore((s) => s.pushMessage);
const markFeedbackSaved = useCoachStore((s) => s.markFeedbackSaved);
const setThinking = useCoachStore((s) => s.setThinking);
const setWelcomeBackShown = useCoachStore((s) => s.setWelcomeBackShown);
const [input, setInput] = useState('');
const [emotion, setEmotion] = useState<Emotion>('idle');
const [isSpeaking, setIsSpeaking] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [isTranscribing, setIsTranscribing] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const recordingRef = useRef<Audio.Recording | null>(null);
const soundRef = useRef<Audio.Sound | null>(null);
const micHeld = useRef(false);
const recordingTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const typingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const emotionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isNearBottomRef = useRef(true);
// Load history + welcome-back. Beide Side-Effects sind store-cached:
// - historyLoaded → kein Re-Fetch + kein Spinner-Blink bei Tab-Wechsel
// - welcomeBackShownThisSession → keine doppelte Lyra-Begrüßung
useEffect(() => {
let cancelled = false;
// Aktuelle Werte aus dem Store lesen (statt Closure-Stale beim ersten Render).
const snap = useCoachStore.getState();
const needsHistory = !snap.historyLoaded;
const needsWelcomeBack = !snap.welcomeBackShownThisSession;
if (!needsHistory && !needsWelcomeBack) {
// Coach war diese Session schon offen → instant rendern, kein Spinner.
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false }));
return;
}
async function init() {
// Spinner nur wenn wir wirklich History fetchen müssen (erster Coach-Open).
if (needsHistory) setIsLoading(true);
if (needsHistory) {
try {
await loadHistory();
} catch {
// non-fatal
}
}
if (cancelled) return;
if (needsWelcomeBack) {
try {
const res = await apiFetch<{ message?: string }>('/api/lyra/welcome-back');
if (!cancelled && res?.message) {
pushMessage({ id: 'wb-' + Date.now(), role: 'assistant', content: res.message });
}
} catch {
// no welcome-back — silent
} finally {
if (!cancelled) setWelcomeBackShown(true);
}
}
if (!cancelled) {
if (needsHistory) setIsLoading(false);
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: false }));
}
}
init();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
// iOS: Will*-Events feuern VOR der Keyboard-Animation → paddingBottom
// ändert sich synchron mit KeyboardAvoidingView. Sonst springt der
// Input erst hoch, dann nach unten ("Zucken").
// Android: Will*-Events feuern unzuverlässig → Did* ist der stabile Pfad.
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const show = Keyboard.addListener(showEvent, (e) => setKeyboardHeight(e.endCoordinates.height));
const hide = Keyboard.addListener(hideEvent, () => setKeyboardHeight(0));
return () => { show.remove(); hide.remove(); };
}, []);
// Enrich messages with timestamp
const enrichedMessages: MessageWithMeta[] = messages.map((msg) => ({
...msg,
timestamp: new Date(),
}));
// Scroll to bottom when messages change (only when near bottom)
useEffect(() => {
if (messages.length === 0) return;
if (isNearBottomRef.current) {
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true }));
} else {
setShowScrollBtn(true);
}
}, [messages.length, thinking]);
function handleScroll(e: NativeSyntheticEvent<NativeScrollEvent>) {
const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent;
const distFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
isNearBottomRef.current = distFromBottom < 80;
if (isNearBottomRef.current) setShowScrollBtn(false);
}
function scrollToBottom() {
flatRef.current?.scrollToEnd({ animated: true });
setShowScrollBtn(false);
}
function handleInputChange(text: string) {
setInput(text);
if (text.length > 0) {
setEmotion((e) => (e === 'thinking' ? e : 'happy'));
if (typingTimer.current) clearTimeout(typingTimer.current);
typingTimer.current = setTimeout(() => {
setEmotion((e) => (e === 'happy' ? 'idle' : e));
}, 2500);
} else {
if (typingTimer.current) clearTimeout(typingTimer.current);
setEmotion((e) => (e === 'happy' ? 'idle' : e));
}
}
function scheduleEmotionReset(delay = 4000) {
if (emotionTimer.current) clearTimeout(emotionTimer.current);
emotionTimer.current = setTimeout(() => setEmotion('idle'), delay);
}
async function handleSend() {
const content = input.trim();
if (!content || thinking) return;
const userMsgId = Date.now().toString();
pushMessage({ id: userMsgId, role: 'user', content });
setInput('');
setThinking(true);
setEmotion('thinking');
try {
const res = await sendMessage(content, i18n.language);
if (res.feedbackSaved) markFeedbackSaved(userMsgId);
pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: res.message });
const e = detectEmotion(res.message);
setEmotion(e);
scheduleEmotionReset(e === 'empathy' ? 6000 : 4000);
} catch {
pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), isError: true });
setEmotion('idle');
} finally {
setThinking(false);
}
}
async function handleVoiceSend(text: string) {
if (!text.trim() || thinking) return;
const userMsgId = Date.now().toString();
pushMessage({ id: userMsgId, role: 'user', content: text });
setThinking(true);
setEmotion('thinking');
try {
const res = await sendMessage(text, i18n.language);
if (res.feedbackSaved) markFeedbackSaved(userMsgId);
pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: res.message });
const e = detectEmotion(res.message);
setEmotion(e);
try {
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
const session = (await supabase.auth.getSession()).data.session;
const ttsRes = await fetch(`${apiBase}/api/coach/speak-openai`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
body: JSON.stringify({ text: res.message, locale: i18n.language }),
});
if (ttsRes.ok) {
// Backend streamt audio/mpeg direkt — als ArrayBuffer holen, base64-encoden, in tmp-File schreiben.
const buffer = await ttsRes.arrayBuffer();
if (buffer.byteLength === 0) {
console.warn('[tts] empty audio buffer');
} else {
const bytes = new Uint8Array(buffer);
// Chunked base64-Encoding (RN btoa kann mit großen Strings überlaufen)
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(
...bytes.subarray(i, Math.min(i + chunkSize, bytes.length))
);
}
// eslint-disable-next-line no-undef
const base64 = global.btoa ? global.btoa(binary) : Buffer.from(binary, 'binary').toString('base64');
const tmpPath = `${FileSystem.cacheDirectory}lyra-tts-${Date.now()}.mp3`;
await FileSystem.writeAsStringAsync(tmpPath, base64, {
encoding: FileSystem.EncodingType.Base64,
});
const { sound } = await Audio.Sound.createAsync({ uri: tmpPath });
soundRef.current = sound;
setIsSpeaking(true);
sound.setOnPlaybackStatusUpdate((status) => {
if (status.isLoaded && status.didJustFinish) {
setIsSpeaking(false);
scheduleEmotionReset(0);
sound.unloadAsync();
}
});
await sound.playAsync();
}
} else {
console.warn('[tts] backend error:', ttsRes.status, await ttsRes.text());
}
} catch (err) {
console.warn('[tts] exception:', err);
scheduleEmotionReset(e === 'empathy' ? 6000 : 4000);
}
} catch {
pushMessage({ id: (Date.now() + 1).toString(), role: 'assistant', content: t('coach.error'), isError: true });
setEmotion('idle');
} finally {
setThinking(false);
}
}
function stopSpeaking() {
soundRef.current?.stopAsync();
soundRef.current?.unloadAsync();
soundRef.current = null;
setIsSpeaking(false);
setEmotion('idle');
}
function startRecordingTimer() {
setRecordingDuration(0);
recordingTimer.current = setInterval(() => setRecordingDuration((d) => d + 1), 1000);
}
function stopRecordingTimer() {
if (recordingTimer.current) clearInterval(recordingTimer.current);
recordingTimer.current = null;
setRecordingDuration(0);
}
async function onMicDown() {
if (thinking || isTranscribing || isRecording || micHeld.current) return;
if (isSpeaking) stopSpeaking();
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') return;
micHeld.current = true;
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
const rec = new Audio.Recording();
try {
await rec.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY);
await rec.startAsync();
recordingRef.current = rec;
setIsRecording(true);
startRecordingTimer();
} catch {
micHeld.current = false;
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
}
}
async function cancelRecording() {
if (!isRecording) return;
micHeld.current = false;
stopRecordingTimer();
setIsRecording(false);
try {
await recordingRef.current?.stopAndUnloadAsync();
} catch { /* already stopped */ }
recordingRef.current = null;
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
}
async function onMicUp() {
if (!micHeld.current || !isRecording) return;
micHeld.current = false;
stopRecordingTimer();
setIsRecording(false);
const rec = recordingRef.current;
recordingRef.current = null;
if (!rec) return;
try {
await rec.stopAndUnloadAsync();
} catch { /* already stopped */ }
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
const uri = rec.getURI();
console.log('[voice] URI after stop:', uri);
if (!uri) return;
setIsTranscribing(true);
setEmotion('happy');
try {
// Backend erwartet base64-Audio in JSON-Body (NICHT FormData):
// { audio: base64String, mimeType: 'audio/m4a', language: 'de' }
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
const session = (await supabase.auth.getSession()).data.session;
const apiUrl = Constants.expoConfig?.extra?.apiUrl as string;
console.log('[voice] POST', `${apiUrl}/api/coach/transcribe`, 'language:', i18n.language, 'base64-bytes:', base64.length);
const res = await fetch(`${apiUrl}/api/coach/transcribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}),
},
body: JSON.stringify({
audio: base64,
mimeType: 'audio/m4a',
language: i18n.language,
}),
});
const json = await res.json();
console.log('[voice] transcribe response:', JSON.stringify(json).slice(0, 200));
const text: string = json?.data?.text ?? json?.text ?? '';
console.log('[voice] extracted text:', JSON.stringify(text));
if (text.trim()) {
await handleVoiceSend(text);
}
} catch (err) {
console.warn('[voice] transcribe error:', err);
} finally {
setIsTranscribing(false);
setEmotion((e) => (e === 'happy' ? 'idle' : e));
}
}
const handleNewChat = useCallback(async () => {
await clearHistory();
setEmotion('idle');
}, [clearHistory]);
const renderMessage = useCallback(
({ item }: { item: MessageWithMeta }) => <MessageRow item={item} t={t} />,
[t]
);
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Backdrop hinter topBar — verhindert dass Chat-Texte hinter dem Avatar */}
{/* sichtbar werden ("kollidieren mit Lyra schriftzug"). */}
<View
style={[styles.topBarBackdrop, { height: insets.top + 170 }]}
pointerEvents="none"
/>
{/* Floating header — no bar, avatar + 2 icon buttons hover over chat */}
<View style={[styles.topBar, { top: insets.top + 6 }]}>
<Pressable style={styles.backBtn} onPress={() => router.replace('/(app)' as never)} hitSlop={12}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
<View style={styles.avatarCenter}>
<View pointerEvents="none">
<RiveAvatar emotion={emotion} size="md" />
</View>
<View style={styles.avatarMeta}>
<Text style={styles.avatarName}>{t('coach.lyra')}</Text>
{isSpeaking && (
<View style={styles.speakingRow}>
<VoiceBars count={5} baseColor={colors.brandOrange} />
<Text style={[styles.speakingLabel, { color: colors.brandOrange }]}>{t('coach.speaking')}</Text>
<Pressable style={styles.stopBtn} onPress={stopSpeaking} hitSlop={6}>
<Ionicons name="square" size={10} color={colors.brandOrange} />
</Pressable>
</View>
)}
</View>
</View>
<Pressable style={styles.newChatBtn} onPress={handleNewChat} hitSlop={12}>
<Ionicons name="refresh-outline" size={22} color={colors.textMuted} />
</Pressable>
</View>
{/* Content area */}
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={0}
>
{isLoading ? (
<LoadingPulse />
) : (
<View style={{ flex: 1 }}>
<FlatList
ref={flatRef}
data={enrichedMessages}
renderItem={renderMessage}
keyExtractor={(m) => m.id}
contentContainerStyle={[styles.listContent, { paddingTop: insets.top + 180 }]}
showsVerticalScrollIndicator={false}
onScroll={handleScroll}
scrollEventThrottle={100}
onContentSizeChange={() => {
if (isNearBottomRef.current) {
flatRef.current?.scrollToEnd({ animated: false });
}
}}
ListFooterComponent={
thinking ? (
<View style={styles.msgRowAssistant}>
<View style={styles.bubbleAssistant}>
<ThinkingDots />
</View>
</View>
) : null
}
/>
{/* Scroll-to-bottom button */}
{showScrollBtn && (
<Pressable style={styles.scrollDownBtn} onPress={scrollToBottom}>
<Ionicons name="chevron-down" size={18} color="#fff" />
</Pressable>
)}
</View>
)}
{/* Input bar */}
<View style={[styles.inputBar, { paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom) }]}>
{isRecording ? (
<View style={styles.recordingContainer}>
<Pressable style={styles.cancelBtn} onPress={cancelRecording}>
<Ionicons name="trash" size={16} color="#f87171" />
</Pressable>
<View style={styles.pulseDot} />
<Text style={styles.recordingTimer}>{formatDuration(recordingDuration)}</Text>
<View style={{ flex: 1 }}>
<VoiceBars count={18} baseColor="#f87171" />
</View>
</View>
) : isTranscribing ? (
<View style={styles.transcribingRow}>
<Ionicons name="sync" size={16} color={colors.textMuted} />
<Text style={styles.transcribingText}>{t('coach.transcribing')}</Text>
</View>
) : (
<TextInput
style={styles.textInput}
placeholder={t('coach.placeholder')}
placeholderTextColor="#a3a3a3"
value={input}
onChangeText={handleInputChange}
multiline
maxLength={1000}
returnKeyType="send"
onSubmitEditing={handleSend}
/>
)}
{!isTranscribing && (
<Pressable
style={[styles.micBtn, isRecording && styles.micBtnActive, thinking && styles.micBtnDisabled]}
onPressIn={onMicDown}
onPressOut={onMicUp}
disabled={thinking}
>
<Ionicons
name={isRecording ? 'square' : 'mic'}
size={18}
color={isRecording ? '#fff' : colors.textMuted}
/>
</Pressable>
)}
{!isRecording && !isTranscribing && input.trim() !== '' && (
<Pressable
style={[styles.sendBtn, thinking && styles.sendBtnDisabled]}
onPress={handleSend}
disabled={thinking || !input.trim()}
>
<Ionicons name="send" size={16} color="#fff" />
</Pressable>
)}
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#ffffff',
},
topBar: {
// Floating-Overlay: schwebt über Chat, kein eigener Header-Block
// top wird inline gesetzt (insets.top + 6) damit Avatar UNTER der Notch sitzt
position: 'absolute',
left: 0,
right: 0,
zIndex: 10,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
paddingHorizontal: 12,
pointerEvents: 'box-none',
},
topBarBackdrop: {
// Solider Hintergrund unter dem Floating-Avatar — Chat-Messages
// scrollen darunter durch, werden aber nicht mehr sichtbar mit Lyra
// Avatar/Name kollidieren.
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 9,
backgroundColor: '#ffffff',
},
backBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.92)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 4,
},
avatarCenter: {
flex: 1,
alignItems: 'center',
gap: 4,
},
avatarMeta: {
alignItems: 'center',
gap: 2,
},
avatarName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
},
statusLabel: {
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
},
speakingRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
speakingLabel: {
fontSize: 11,
fontFamily: 'Nunito_600SemiBold',
},
stopBtn: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
},
newChatBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.92)',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 6,
elevation: 4,
},
listContent: {
paddingHorizontal: 12,
// Reserve Platz für den Floating-Avatar oben (Avatar 112px + Name + Status + Gaps + Margin)
paddingTop: 210,
paddingBottom: 12,
},
// Insta-DM row
msgRow: {
flexDirection: 'row',
marginBottom: 4,
alignItems: 'flex-end',
},
msgRowUser: {
justifyContent: 'flex-end',
},
msgRowAssistant: {
justifyContent: 'flex-start',
},
assistantAvatarSlot: {
width: 28,
marginRight: 6,
alignItems: 'center',
justifyContent: 'flex-end',
},
bubbleCol: {
maxWidth: '75%',
gap: 2,
},
bubbleColUser: {
alignItems: 'flex-end',
},
bubbleColAssistant: {
alignItems: 'flex-start',
},
bubble: {
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 9,
},
bubbleUser: {
backgroundColor: '#007AFF',
borderBottomRightRadius: 4,
},
bubbleAssistant: {
backgroundColor: '#f0f0f0',
borderBottomLeftRadius: 4,
},
bubbleText: {
fontSize: 15,
lineHeight: 21,
fontFamily: 'Nunito_400Regular',
},
bubbleTextUser: {
color: '#ffffff',
},
bubbleTextAssistant: {
color: '#0a0a0a',
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 4,
marginBottom: 4,
},
metaRowUser: {
justifyContent: 'flex-end',
},
metaRowAssistant: {
justifyContent: 'flex-start',
},
feedbackText: {
fontSize: 10,
color: '#16a34a',
fontFamily: 'Nunito_400Regular',
},
timestampText: {
fontSize: 10,
color: '#a3a3a3',
fontFamily: 'Nunito_400Regular',
},
thinkingRow: {
flexDirection: 'row',
gap: 4,
alignItems: 'center',
paddingVertical: 4,
},
thinkingDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#d4d4d4',
},
scrollDownBtn: {
position: 'absolute',
bottom: 12,
right: 16,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 4,
},
inputBar: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
paddingHorizontal: 12,
paddingTop: 8,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#e5e5e5',
backgroundColor: 'rgba(255,255,255,0.97)',
},
textInput: {
flex: 1,
backgroundColor: '#f5f5f5',
borderRadius: 22,
paddingVertical: 9,
paddingHorizontal: 16,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
maxHeight: 120,
},
micBtn: {
width: 38,
height: 38,
borderRadius: 19,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
},
micBtnActive: {
backgroundColor: '#dc2626',
transform: [{ scale: 1.1 }],
},
micBtnDisabled: {
opacity: 0.4,
},
sendBtn: {
width: 38,
height: 38,
borderRadius: 19,
backgroundColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
},
sendBtnDisabled: {
opacity: 0.4,
},
recordingContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'rgba(220,38,38,0.08)',
borderWidth: 1,
borderColor: 'rgba(220,38,38,0.2)',
borderRadius: 22,
paddingHorizontal: 12,
paddingVertical: 8,
},
cancelBtn: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(220,38,38,0.15)',
alignItems: 'center',
justifyContent: 'center',
},
pulseDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: '#dc2626',
},
recordingTimer: {
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#f87171',
fontVariant: ['tabular-nums'],
},
transcribingRow: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingVertical: 10,
},
transcribingText: {
fontSize: 14,
color: '#737373',
fontFamily: 'Nunito_400Regular',
},
});

View File

@ -0,0 +1,856 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
View,
Text,
FlatList,
Pressable,
Image,
Modal,
TextInput,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
StyleSheet,
ScrollView,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { apiFetch } from '../lib/api';
import { supabase } from '../lib/supabase';
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
import { useRoomRealtime } from '../hooks/useChatRealtime';
import { colors } from '../lib/theme';
const GROUP_GAP_MS = 5 * 60 * 1000;
type RoomDetail = {
room: {
id: string;
name: string;
description: string | null;
isPublic: boolean;
isDefault: boolean;
joinMode: 'approval' | 'invite_only' | 'open';
avatarUrl: string | null;
inviteCode: string | null;
memberCount: number;
createdBy: string;
myRole: 'owner' | 'admin' | 'member' | null;
isMember: boolean;
};
members: Array<{ userId: string; role: string; nickname: string; avatar: string | null }>;
messages: Array<any>;
hasMore: boolean;
};
function decodeBase64(b64: string): Uint8Array {
const binary = (globalThis as any).atob
? (globalThis as any).atob(b64)
: Buffer.from(b64, 'base64').toString('binary');
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
export default function RoomScreen() {
const { t } = useTranslation();
const router = useRouter();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const flatRef = useRef<FlatList>(null);
const [myUserId, setMyUserId] = useState<string | undefined>();
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const [messages, setMessages] = useState<ChatMsg[]>([]);
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
null,
);
const [sending, setSending] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [joining, setJoining] = useState(false);
const [joinStatus, setJoinStatus] = useState<'joined' | 'pending' | null>(null);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => setMyUserId(data.session?.user.id));
}, []);
const { data, isLoading, refetch } = useQuery<RoomDetail>({
queryKey: ['chat-room', roomId],
queryFn: async () => {
const d = await apiFetch<RoomDetail>(`/api/chat/rooms/${roomId}`);
const msgs: ChatMsg[] = d.messages.map((m: any) => ({
id: m.id,
userId: m.userId,
nickname: m.nickname,
avatar: m.avatar,
content: m.content,
replyTo: m.replyTo
? {
id: m.replyTo.id,
userId: m.replyTo.userId,
nickname: m.replyTo.nickname,
content: m.replyTo.content,
attachmentType: m.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: m.attachmentUrl,
attachmentType: m.attachmentType,
attachmentName: m.attachmentName,
likesCount: m.likesCount ?? 0,
likedByMe: false,
createdAt: m.createdAt,
isOwn: m.isOwn,
}));
setMessages(msgs);
return d;
},
enabled: !!roomId,
});
const room = data?.room;
const members = data?.members ?? [];
const isAdmin = room?.myRole === 'owner' || room?.myRole === 'admin';
// Realtime: neue Messages anderer User
const onRoomInsert = useCallback(
(row: any) => {
const sender = members.find((m) => m.userId === row.user_id);
setMessages((prev) => {
if (prev.some((m) => m.id === row.id)) return prev;
return [
...prev,
{
id: row.id,
userId: row.user_id,
nickname: sender?.nickname ?? 'Anonym',
avatar: sender?.avatar ?? null,
content: row.content ?? '',
replyTo: null,
attachmentUrl: row.attachment_url ?? null,
attachmentType: row.attachment_type ?? null,
attachmentName: row.attachment_name ?? null,
likesCount: 0,
likedByMe: false,
createdAt: row.created_at,
isOwn: false,
},
];
});
},
[members],
);
useRoomRealtime(roomId, myUserId, onRoomInsert, !!myUserId && !!room?.isMember);
useEffect(() => {
if (messages.length > 0) {
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true }));
}
}, [messages.length]);
async function handleSend(payload: SendPayload) {
if (sending || !room?.isMember) return;
setSending(true);
try {
const newMsg = await apiFetch<any>(`/api/chat/rooms/${roomId}/messages`, {
method: 'POST',
body: payload,
});
setMessages((prev) => [
...prev,
{
id: newMsg.id,
userId: myUserId ?? '',
nickname: 'Du',
avatar: null,
content: newMsg.content,
replyTo: newMsg.replyTo
? {
id: newMsg.replyTo.id,
userId: newMsg.replyTo.userId,
nickname: newMsg.replyTo.nickname,
content: newMsg.replyTo.content,
attachmentType: newMsg.replyTo.attachmentType ?? null,
}
: null,
attachmentUrl: newMsg.attachmentUrl,
attachmentType: newMsg.attachmentType,
attachmentName: newMsg.attachmentName,
likesCount: 0,
likedByMe: false,
createdAt: newMsg.createdAt,
isOwn: true,
},
]);
setReplyTo(null);
} catch (err) {
console.error('Room send failed:', err);
} finally {
setSending(false);
}
}
async function toggleLike(msg: ChatMsg) {
try {
const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', {
method: 'POST',
body: { messageId: msg.id, type: 'chat' },
});
setMessages((prev) =>
prev.map((m) =>
m.id === msg.id
? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) }
: m,
),
);
} catch {}
}
function startReply(msg: ChatMsg) {
setReplyTo({
id: msg.id,
nickname: msg.nickname ?? '?',
content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''),
});
}
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
if (!a || !b) return false;
if (a.userId !== b.userId) return false;
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
}
async function handleJoin() {
if (joining) return;
setJoining(true);
try {
const res = await apiFetch<{ status: 'joined' | 'pending' | 'already_member' }>(
`/api/chat/rooms/${roomId}/join`,
{ method: 'POST' },
);
if (res.status === 'pending') {
setJoinStatus('pending');
} else {
setJoinStatus('joined');
await refetch();
queryClient.invalidateQueries({ queryKey: ['chat-rooms'] });
}
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Beitritt fehlgeschlagen');
} finally {
setJoining(false);
}
}
async function handleAvatarUpload() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
quality: 0.8,
allowsEditing: true,
aspect: [1, 1],
});
if (result.canceled || !result.assets[0]) return;
const asset = result.assets[0];
try {
const ext = asset.uri.split('.').pop()?.toLowerCase() || 'jpg';
const path = `room-avatars/${roomId}-${Date.now()}.${ext}`;
const b64 = await FileSystem.readAsStringAsync(asset.uri, {
encoding: FileSystem.EncodingType.Base64,
});
const bytes = decodeBase64(b64);
const { error: upErr } = await supabase.storage
.from('rebreak-public')
.upload(path, bytes, {
contentType: asset.mimeType ?? `image/${ext}`,
upsert: true,
});
if (upErr) throw upErr;
const { data: pub } = supabase.storage.from('rebreak-public').getPublicUrl(path);
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { avatarUrl: pub.publicUrl },
});
await refetch();
queryClient.invalidateQueries({ queryKey: ['chat-rooms'] });
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? t('chat.upload_failed'));
}
}
const initials = (room?.name ?? '?')
.split(' ')
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('');
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.iconBtn} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
</Pressable>
<View style={styles.headerCenter}>
<View style={styles.headerAvatar}>
{room?.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} />
) : (
<Text style={styles.headerAvatarInitials}>{initials}</Text>
)}
</View>
<View style={{ flexShrink: 1 }}>
<Text style={styles.headerName} numberOfLines={1}>
{room?.name ?? '…'}
</Text>
{room && (
<Text style={styles.headerSub} numberOfLines={1}>
{t('chat.member_count', { n: room.memberCount })}
</Text>
)}
</View>
</View>
<Pressable style={styles.iconBtn} onPress={() => setSettingsOpen(true)} hitSlop={8}>
<Ionicons name="ellipsis-horizontal" size={20} color="#0a0a0a" />
</Pressable>
</View>
{isLoading || !room ? (
<View style={styles.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : !room.isMember ? (
<View style={styles.joinBox}>
<Ionicons name="people" size={48} color="#d4d4d4" />
<Text style={styles.joinTitle}>{room.name}</Text>
{room.description && <Text style={styles.joinDesc}>{room.description}</Text>}
<Text style={styles.joinHint}>{t('chat.join_required')}</Text>
{joinStatus === 'pending' ? (
<View style={styles.pendingBadge}>
<Ionicons name="time-outline" size={14} color="#92400e" />
<Text style={styles.pendingText}>{t('chat.join_pending')}</Text>
</View>
) : (
<Pressable
onPress={handleJoin}
disabled={joining}
style={({ pressed }) => [
styles.joinBtn,
{ opacity: pressed || joining ? 0.7 : 1 },
]}
>
{joining ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.joinBtnText}>{t('chat.join')}</Text>
)}
</Pressable>
)}
</View>
) : (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<FlatList
ref={flatRef}
data={messages}
style={{ flex: 1 }}
renderItem={({ item, index }) => (
<ChatBubble
msg={item}
showName
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
isLastInGroup={!sameAuthor(item, messages[index + 1])}
hideReadStatus
onReply={startReply}
onLike={toggleLike}
onOpenImage={() => {}}
/>
)}
keyExtractor={(m) => m.id}
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
/>
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
<ChatInput
replyTo={replyTo}
sending={sending}
onSend={handleSend}
onCancelReply={() => setReplyTo(null)}
/>
</View>
</KeyboardAvoidingView>
)}
{/* Settings Modal */}
<RoomSettingsModal
visible={settingsOpen}
onClose={() => setSettingsOpen(false)}
room={room}
members={members}
isAdmin={isAdmin}
onAvatarChange={handleAvatarUpload}
onRefetch={refetch}
roomId={roomId}
/>
</SafeAreaView>
);
}
// ----- Settings Modal -----
function RoomSettingsModal({
visible,
onClose,
room,
members,
isAdmin,
onAvatarChange,
onRefetch,
roomId,
}: {
visible: boolean;
onClose: () => void;
room: RoomDetail['room'] | undefined;
members: RoomDetail['members'];
isAdmin: boolean;
onAvatarChange: () => void;
onRefetch: () => void;
roomId: string;
}) {
const { t } = useTranslation();
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
const [loadingReqs, setLoadingReqs] = useState(false);
useEffect(() => {
if (!visible || !isAdmin) return;
setLoadingReqs(true);
apiFetch<{ requests: any[] }>(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action: 'list_requests' },
})
.then((d: any) => setPendingRequests(d?.requests ?? d ?? []))
.catch(() => setPendingRequests([]))
.finally(() => setLoadingReqs(false));
}, [visible, isAdmin, roomId]);
async function handleRequest(userId: string, action: 'approve' | 'reject') {
try {
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action, userId },
});
setPendingRequests((prev) => prev.filter((r) => r.userId !== userId));
onRefetch();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen');
}
}
async function handlePromote(userId: string) {
try {
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action: 'promote_admin', userId },
});
onRefetch();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen');
}
}
async function handleBan(userId: string) {
Alert.alert('Bannen?', 'User wird aus dem Raum entfernt.', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: 'Bannen',
style: 'destructive',
onPress: async () => {
try {
await apiFetch(`/api/chat/rooms/${roomId}`, {
method: 'PATCH',
body: { action: 'ban', userId },
});
onRefetch();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Aktion fehlgeschlagen');
}
},
},
]);
}
async function handleLeave() {
Alert.alert(t('chat.leave_room'), '', [
{ text: 'Abbrechen', style: 'cancel' },
{
text: t('chat.leave_room'),
style: 'destructive',
onPress: async () => {
try {
await apiFetch(`/api/chat/rooms/${roomId}/leave`, { method: 'POST' });
onClose();
} catch (err: any) {
Alert.alert('Fehler', err?.message ?? 'Verlassen fehlgeschlagen');
}
},
},
]);
}
if (!room) return null;
return (
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" onRequestClose={onClose}>
<SafeAreaView style={modal.container} edges={['top']}>
<View style={modal.header}>
<Pressable onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={24} color="#0a0a0a" />
</Pressable>
<Text style={modal.title}>{t('chat.settings')}</Text>
<View style={{ width: 24 }} />
</View>
<ScrollView contentContainerStyle={{ padding: 16, paddingBottom: 60 }}>
{/* Avatar + Name */}
<View style={modal.section}>
<Pressable
onPress={isAdmin ? onAvatarChange : undefined}
style={modal.avatarWrap}
>
{room.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} />
) : (
<View style={[modal.avatar, modal.avatarPlaceholder]}>
<Ionicons name="people" size={32} color="#737373" />
</View>
)}
{isAdmin && (
<View style={modal.avatarEdit}>
<Ionicons name="camera" size={14} color="#fff" />
</View>
)}
</Pressable>
<Text style={modal.roomName}>{room.name}</Text>
{room.description && <Text style={modal.roomDesc}>{room.description}</Text>}
</View>
{/* Pending Requests */}
{isAdmin && (
<View style={modal.section}>
<Text style={modal.sectionTitle}>{t('chat.pending_request')}</Text>
{loadingReqs ? (
<ActivityIndicator color={colors.brandOrange} style={{ marginVertical: 12 }} />
) : pendingRequests.length === 0 ? (
<Text style={modal.emptyText}></Text>
) : (
pendingRequests.map((req) => (
<View key={req.userId} style={modal.memberRow}>
<View style={{ flex: 1 }}>
<Text style={modal.memberName}>{req.nickname ?? 'Anonym'}</Text>
</View>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#dcfce7' }]}
onPress={() => handleRequest(req.userId, 'approve')}
>
<Text style={[modal.actionText, { color: '#166534' }]}>
{t('chat.approve')}
</Text>
</Pressable>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
onPress={() => handleRequest(req.userId, 'reject')}
>
<Text style={[modal.actionText, { color: '#991b1b' }]}>
{t('chat.reject')}
</Text>
</Pressable>
</View>
))
)}
</View>
)}
{/* Members */}
<View style={modal.section}>
<Text style={modal.sectionTitle}>
{t('chat.members')} ({members.length})
</Text>
{members.map((m) => (
<View key={m.userId} style={modal.memberRow}>
<View style={modal.memberAvatar}>
{m.avatar ? (
<Image source={{ uri: m.avatar }} style={modal.memberAvatarImg} />
) : (
<Text style={modal.memberInitials}>
{m.nickname.slice(0, 2).toUpperCase()}
</Text>
)}
</View>
<View style={{ flex: 1 }}>
<Text style={modal.memberName}>{m.nickname}</Text>
{m.role !== 'member' && (
<Text style={modal.memberRole}>{m.role}</Text>
)}
</View>
{isAdmin && m.role === 'member' && (
<>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fef3c7' }]}
onPress={() => handlePromote(m.userId)}
>
<Text style={[modal.actionText, { color: '#92400e' }]}>Admin</Text>
</Pressable>
<Pressable
style={[modal.actionBtn, { backgroundColor: '#fee2e2', marginLeft: 6 }]}
onPress={() => handleBan(m.userId)}
>
<Text style={[modal.actionText, { color: '#991b1b' }]}>Ban</Text>
</Pressable>
</>
)}
</View>
))}
</View>
{/* Leave */}
{!room.isDefault && (
<Pressable style={modal.leaveBtn} onPress={handleLeave}>
<Ionicons name="exit-outline" size={18} color="#991b1b" />
<Text style={modal.leaveText}>{t('chat.leave_room')}</Text>
</Pressable>
)}
</ScrollView>
</SafeAreaView>
</Modal>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
iconBtn: {
width: 36,
height: 36,
borderRadius: 12,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginHorizontal: 8,
},
headerAvatar: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 8,
},
headerAvatarImg: { width: 36, height: 36 },
headerAvatarInitials: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: '#737373',
},
headerName: {
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#171717',
},
headerSub: {
fontSize: 11,
fontFamily: 'Nunito_500Medium',
color: '#737373',
marginTop: 1,
},
loadingBox: { flex: 1, alignItems: 'center', justifyContent: 'center' },
joinBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
},
joinTitle: {
fontSize: 20,
fontFamily: 'Nunito_700Bold',
color: '#171717',
marginTop: 14,
},
joinDesc: {
fontSize: 13,
fontFamily: 'Nunito_500Medium',
color: '#737373',
marginTop: 6,
textAlign: 'center',
},
joinHint: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: '#a3a3a3',
marginTop: 18,
textAlign: 'center',
},
joinBtn: {
marginTop: 16,
backgroundColor: '#007AFF',
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 12,
minWidth: 140,
alignItems: 'center',
},
joinBtnText: {
color: '#fff',
fontSize: 14,
fontFamily: 'Nunito_700Bold',
},
pendingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fef3c7',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 20,
marginTop: 16,
},
pendingText: {
color: '#92400e',
fontSize: 12,
fontFamily: 'Nunito_700Bold',
marginLeft: 6,
},
});
const modal = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fafafa' },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e5e5',
},
title: { fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#171717' },
section: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 14,
marginBottom: 12,
},
sectionTitle: {
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: '#737373',
textTransform: 'uppercase',
marginBottom: 10,
letterSpacing: 0.5,
},
avatarWrap: { alignSelf: 'center', marginBottom: 10 },
avatar: { width: 80, height: 80, borderRadius: 40 },
avatarPlaceholder: {
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
},
avatarEdit: {
position: 'absolute',
right: -2,
bottom: -2,
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#007AFF',
borderWidth: 3,
borderColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
roomName: {
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#171717',
textAlign: 'center',
},
roomDesc: {
fontSize: 12,
fontFamily: 'Nunito_500Medium',
color: '#737373',
textAlign: 'center',
marginTop: 4,
},
memberRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
},
memberAvatar: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 10,
},
memberAvatarImg: { width: 32, height: 32 },
memberInitials: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
},
memberName: { fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#171717' },
memberRole: { fontSize: 11, color: '#a3a3a3', marginTop: 1, textTransform: 'capitalize' },
actionBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
},
actionText: { fontSize: 11, fontFamily: 'Nunito_700Bold' },
emptyText: { fontSize: 12, color: '#a3a3a3' },
leaveBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#fee2e2',
paddingVertical: 12,
borderRadius: 10,
marginTop: 8,
},
leaveText: {
color: '#991b1b',
fontSize: 13,
fontFamily: 'Nunito_700Bold',
marginLeft: 6,
},
});

View File

@ -0,0 +1,222 @@
import { ScrollView, View, Text, Pressable, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
import { Card } from '../components/Card';
import { Button } from '../components/Button';
import { colors } from '../lib/theme';
type SettingRow = {
label: string;
sublabel?: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
iconColor: string;
onPress?: () => void;
right?: React.ReactNode;
};
export default function SettingsScreen() {
const router = useRouter();
const { t } = useTranslation();
const { user, signOut } = useAuthStore();
const [notifPush, setNotifPush] = useState(true);
const [notifStreak, setNotifStreak] = useState(true);
const email = user?.email ?? '';
const initials = email.slice(0, 2).toUpperCase();
async function handleSignOut() {
await signOut();
router.replace('/');
}
const accountRows: SettingRow[] = [
{
label: t('settings.edit_profile'),
icon: 'pencil-outline',
iconColor: '#6366f1',
onPress: () => {},
},
{
label: t('settings.devices'),
sublabel: t('settings.devices_desc'),
icon: 'phone-portrait-outline',
iconColor: '#16a34a',
onPress: () => {},
},
{
label: t('settings.subscription'),
sublabel: t('settings.plan_free'),
icon: 'star-outline',
iconColor: colors.brandOrange,
onPress: () => {},
},
];
const prefRows: SettingRow[] = [
{
label: t('settings.push_notifications'),
icon: 'notifications-outline',
iconColor: '#2563eb',
right: (
<Switch
value={notifPush}
onValueChange={setNotifPush}
trackColor={{ false: '#e5e5e5', true: colors.brandOrange + '60' }}
thumbColor={notifPush ? colors.brandOrange : '#a3a3a3'}
/>
),
},
{
label: t('settings.streak_reminders'),
icon: 'flame-outline',
iconColor: '#f97316',
right: (
<Switch
value={notifStreak}
onValueChange={setNotifStreak}
trackColor={{ false: '#e5e5e5', true: colors.brandOrange + '60' }}
thumbColor={notifStreak ? colors.brandOrange : '#a3a3a3'}
/>
),
},
{
label: t('settings.language'),
sublabel: t('settings.language_current'),
icon: 'language-outline',
iconColor: '#a78bfa',
onPress: () => {},
},
];
return (
<SafeAreaView className="flex-1 bg-neutral-50" edges={['top']}>
<View className="px-3 pt-1 pb-3 flex-row items-center gap-2">
<Pressable
onPress={() => router.replace('/(app)' as never)}
hitSlop={8}
className="w-10 h-10 items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</Pressable>
<Text className="text-neutral-900 text-xl" style={{ fontFamily: 'Nunito_700Bold' }}>{t('settings.title')}</Text>
</View>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40, paddingTop: 4 }}
showsVerticalScrollIndicator={false}
>
{/* Account Card */}
<Card className="mb-5">
<View className="flex-row items-center gap-3">
<View className="w-14 h-14 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-lg" style={{ fontFamily: 'Nunito_800ExtraBold' }}>{initials}</Text>
</View>
<View className="flex-1">
<Text className="text-neutral-900 text-base" numberOfLines={1} style={{ fontFamily: 'Nunito_700Bold' }}>
{email}
</Text>
<View className="flex-row items-center gap-1.5 mt-1">
<View className="w-1.5 h-1.5 rounded-full bg-green-500" />
<Text className="text-neutral-500 text-xs" style={{ fontFamily: 'Nunito_400Regular' }}>{t('settings.plan_free')}</Text>
</View>
</View>
</View>
<View className="mt-4 pt-3 border-t border-neutral-100">
<Button variant="secondary" onPress={() => {}}>
{t('settings.upgrade_cta')}
</Button>
</View>
</Card>
{/* Account Section */}
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('settings.account_section')}
</Text>
<Card className="mb-5 py-0 overflow-hidden">
{accountRows.map((row, i) => (
<Pressable
key={row.label}
onPress={row.onPress}
className={`flex-row items-center gap-3 px-4 py-3.5 ${
i < accountRows.length - 1 ? 'border-b border-neutral-100' : ''
}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<View
className="w-8 h-8 rounded-xl items-center justify-center"
style={{ backgroundColor: row.iconColor + '18' }}
>
<Ionicons name={row.icon} size={16} color={row.iconColor} />
</View>
<View className="flex-1">
<Text className="text-neutral-800 text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{row.label}</Text>
{row.sublabel ? (
<Text className="text-neutral-400 text-xs mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>{row.sublabel}</Text>
) : null}
</View>
{row.right ?? (
<Ionicons name="chevron-forward" size={14} color="#a3a3a3" />
)}
</Pressable>
))}
</Card>
{/* Preferences Section */}
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('settings.prefs_section')}
</Text>
<Card className="mb-5 py-0 overflow-hidden">
{prefRows.map((row, i) => (
<View
key={row.label}
className={`flex-row items-center gap-3 px-4 py-3.5 ${
i < prefRows.length - 1 ? 'border-b border-neutral-100' : ''
}`}
>
<View
className="w-8 h-8 rounded-xl items-center justify-center"
style={{ backgroundColor: row.iconColor + '18' }}
>
<Ionicons name={row.icon} size={16} color={row.iconColor} />
</View>
<View className="flex-1">
<Text className="text-neutral-800 text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{row.label}</Text>
{row.sublabel ? (
<Text className="text-neutral-400 text-xs mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>{row.sublabel}</Text>
) : null}
</View>
{row.right ?? (
<Pressable onPress={row.onPress}>
<Ionicons name="chevron-forward" size={14} color="#a3a3a3" />
</Pressable>
)}
</View>
))}
</Card>
{/* Danger Zone */}
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('settings.danger_section')}
</Text>
<Card className="mb-3">
<Button variant="danger" onPress={() => {}} className="mb-2">
{t('settings.delete_account')}
</Button>
<Text className="text-neutral-400 text-xs text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('settings.delete_desc')}
</Text>
</Card>
<Button variant="secondary" onPress={handleSignOut}>
{t('settings.sign_out')}
</Button>
</ScrollView>
</SafeAreaView>
);
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,20 @@
module.exports = function (api) {
api.cache(true);
return {
presets: [
// reanimated: false → Auto-Load von react-native-reanimated/plugin durch
// babel-preset-expo abschalten. Wir laden unten react-native-worklets/plugin
// selbst (das ist der korrekte Plugin für Reanimated 4.x).
[
'babel-preset-expo',
{ jsxImportSource: 'nativewind', reanimated: false },
],
'nativewind/babel',
],
plugins: [
// Reanimated 4.x: Worklets ausgelagert in eigenes Package.
// Plugin muss letzter sein.
'react-native-worklets/plugin',
],
};
};

View File

@ -0,0 +1,83 @@
#!/bin/bash
# Rebreak Native: Nuclear iOS Clean + Rebuild
#
# Wann brauchst du das?
# - Build-Errors die nach Native-Code-Änderung aus dem Nichts kommen
# - "fmt consteval", "Hermes", "RCT-Folly" Compile-Errors in Pods
# - Stale DerivedData-Cache nach Xcode-Upgrade
# - Nach App-Bundle-ID/Entitlement-Änderungen
# - Nach Hinzufügen/Entfernen von Native Modules oder Config-Plugins
#
# Was es NICHT zerstört:
# - Code in app/, components/, modules/, plugins/ (alles was in git getrackt ist)
# - node_modules
#
# Was es zerstört:
# - ios/Pods, ios/Podfile.lock, ios/build (vollständig regeneriert via prebuild)
# - DerivedData-Cache von Xcode (wird beim nächsten Build neu aufgebaut)
#
# Modi:
# ./clean-ios.sh → clean + prebuild + pod install (kein Build)
# ./clean-ios.sh --build → + zusätzlich pnpm ios am Ende (wirft App auf Sim/Device)
# ./clean-ios.sh --xcode → + zum Schluss Xcode-Workspace öffnen
set -e
cd "$(dirname "$0")"
MODE="${1:-}"
echo "🧹 Rebreak Native iOS Clean"
echo "==========================="
echo ""
# 1. Wipe Pods, Lockfile, Build-Output
echo "→ rm -rf ios/Pods ios/Podfile.lock ios/build"
rm -rf ios/Pods ios/Podfile.lock ios/build
# 2. Wipe Xcode DerivedData (nur für Rebreak — nicht das ganze ~/Library)
DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData"
if [ -d "$DERIVED_DATA" ]; then
echo "→ rm -rf $DERIVED_DATA/Rebreak-*"
rm -rf "$DERIVED_DATA"/Rebreak-* 2>/dev/null || true
fi
# 3. Prebuild: regeneriert ios/ aus app.config.ts + Config-Plugins
# Dank with-fmt-consteval-fix-Plugin wird das Podfile auto-gepatcht.
echo "→ pnpm expo prebuild --clean"
pnpm expo prebuild --clean
# 4. Pod install
echo "→ cd ios && pod install"
(cd ios && pod install)
echo ""
echo "✅ Clean done."
echo ""
case "$MODE" in
--build|build)
echo "🔨 Building + running on connected device/simulator..."
pnpm ios
;;
--xcode|xcode)
echo "🔨 Opening Xcode-Workspace..."
osascript -e 'tell application "Xcode" to close every window whose name contains "Rebreak.xcodeproj"' 2>/dev/null || true
open -a Xcode ios/Rebreak.xcworkspace
echo ""
echo " In Xcode: Cmd+R für Build & Run"
;;
"")
echo " Nächste Schritte:"
echo " ./dev-ios.sh # Xcode öffnen (manueller Build)"
echo " pnpm ios # CLI-Build auf Sim/Device"
echo " ./clean-ios.sh --build # alles in einem Rutsch"
;;
*)
echo "Unknown mode: $MODE"
echo "Usage: ./clean-ios.sh [--build|--xcode]"
exit 1
;;
esac

View File

@ -0,0 +1,238 @@
import { useState } from 'react';
import { View, Text, Pressable, Modal, Image } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter, type RelativePathString } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
import { useNotificationStore } from '../stores/notifications';
import { supabase } from '../lib/supabase';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useMe } from '../hooks/useMe';
import { NotificationsDropdown } from './NotificationsDropdown';
type Props = {
notifCount?: number;
};
type MenuItem = {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
color?: string;
action: () => void;
};
export function AppHeader({ notifCount }: Props = {}) {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
const { user } = useAuthStore();
const { me } = useMe();
const storeUnread = useNotificationStore((s) => s.unread);
const badge = notifCount ?? storeUnread;
const [dropdownOpen, setDropdownOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const firstName = (user?.user_metadata?.first_name as string | undefined) ?? '';
const lastName = (user?.user_metadata?.last_name as string | undefined) ?? '';
const email = user?.email ?? '';
// Initials-Fallback: erst nickname (DB), dann firstName/email
const initials = (() => {
if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase();
return ((firstName.charAt(0) + (lastName.charAt(0) || email.charAt(0))).toUpperCase() || '?');
})();
// Avatar: aus DB (`/api/auth/me` → profiles.avatar). Kann Hero-Avatar-ID
// ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload)
// sein. resolveAvatar handlet beide Fälle.
// user_metadata.avatar_id ist veraltet — wird bei Profile-Edit nicht
// aktualisiert. DB ist Single Source of Truth.
const avatarUrl = me ? resolveAvatar(me.avatar, me.nickname ?? '') : '';
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
const showAvatarImage = !!avatarUrl && !avatarLoadFailed && !!me?.avatar;
function closeAndNavigate(path: RelativePathString) {
setDropdownOpen(false);
router.push(path);
}
async function handleSignOut() {
setDropdownOpen(false);
await supabase.auth.signOut();
router.replace('/' as RelativePathString);
}
const menuItems: MenuItem[] = [
{
icon: 'person-outline',
label: t('appHeader.editProfile'),
action: () => closeAndNavigate('/settings' as RelativePathString),
},
{
icon: 'settings-outline',
label: t('appHeader.settings'),
action: () => closeAndNavigate('/settings' as RelativePathString),
},
];
const headerHeight = insets.top + 56;
return (
<View
className="bg-white border-b border-neutral-200"
style={{ paddingTop: insets.top }}
>
<View className="h-14 flex-row items-center justify-between px-5">
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
{t('appHeader.appName')}
</Text>
<View className="flex-row items-center gap-2">
{/* Notifications dropdown trigger */}
<Pressable
onPress={() => setNotifOpen(true)}
className="w-9 h-9 rounded-full bg-white items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<Ionicons name="notifications-outline" size={18} color="#737373" />
{badge > 0 && (
<View className="absolute top-0 right-0 w-4 h-4 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-[9px]" style={{ fontFamily: 'Nunito_700Bold' }}>
{badge > 9 ? '9+' : String(badge)}
</Text>
</View>
)}
</Pressable>
{/* Profil-Avatar — tap → dropdown */}
<Pressable
onPress={() => setDropdownOpen(true)}
className={`w-9 h-9 rounded-full items-center justify-center overflow-hidden ${showAvatarImage ? 'bg-neutral-100' : 'bg-rebreak-500'}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
{showAvatarImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setAvatarLoadFailed(true)}
style={{ width: 36, height: 36, borderRadius: 18 }}
/>
) : (
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>{initials}</Text>
)}
</Pressable>
</View>
</View>
{/* Dropdown modal */}
<Modal
visible={dropdownOpen}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setDropdownOpen(false)}
>
<Pressable
onPress={() => setDropdownOpen(false)}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
>
<View
onStartShouldSetResponder={() => true}
style={{
position: 'absolute',
top: headerHeight + 6,
right: 12,
backgroundColor: '#ffffff',
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
minWidth: 260,
overflow: 'hidden',
}}
>
{/* SOS prominent oben — Pressable mit innerem Row-View */}
<Pressable onPress={() => closeAndNavigate('/urge' as RelativePathString)}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 16,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#fee2e2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}
>
<Ionicons name="heart" size={18} color="#dc2626" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>
{t('appHeader.sosLabel')}
</Text>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}>
{t('appHeader.sosSubtitle')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
</View>
</Pressable>
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
{menuItems.map((item) => (
<Pressable key={item.label} onPress={item.action}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons name={item.icon} size={18} color="#737373" style={{ marginRight: 14 }} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}>
{item.label}
</Text>
</View>
</Pressable>
))}
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
<Pressable onPress={handleSignOut}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons name="log-out-outline" size={18} color="#dc2626" style={{ marginRight: 14 }} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
{t('appHeader.signOut')}
</Text>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
<NotificationsDropdown
visible={notifOpen}
onClose={() => setNotifOpen(false)}
topOffset={headerHeight}
/>
</View>
);
}

View File

@ -0,0 +1,412 @@
import { useEffect, useRef } from 'react';
import { Animated, Dimensions, Image, Text, View } from 'react-native';
import Svg, { Defs, RadialGradient, Rect, Stop } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
// Phase-Timings (ms ab Mount) — 1:1 portiert aus apps/rebreak/app/components/AppSplash.vue
const T_GLOW = 0;
const T_NAME = 300;
const T_LOGO = 700;
const T_PULSE = 1100;
const T_TAGLINE = 1300;
const T_SUB = 1700;
const T_HOLD_END = 3200;
const T_LEAVE_DUR = 500;
const { width: SW, height: SH } = Dimensions.get('window');
type ParticleConfig = {
size: number;
top?: number;
bottom?: number;
left?: number;
right?: number;
duration: number;
delay: number;
};
const PARTICLES: ParticleConfig[] = [
{ size: 180, top: -40, left: -60, duration: 7000, delay: 0 },
{ size: 120, bottom: SH * 0.1, right: -30, duration: 9000, delay: 1500 },
{ size: 80, top: SH * 0.35, left: SW * 0.08, duration: 11000, delay: 800 },
{ size: 60, bottom: SH * 0.2, left: SW * 0.2, duration: 8000, delay: 2200 },
{ size: 100, top: SH * 0.15, right: SW * 0.1, duration: 10000, delay: 400 },
];
function Particle({ config }: { config: ParticleConfig }) {
const translateY = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(1)).current;
const opacity = useRef(new Animated.Value(0.6)).current;
useEffect(() => {
const animate = () => {
Animated.loop(
Animated.sequence([
Animated.parallel([
Animated.timing(translateY, {
toValue: 18,
duration: config.duration,
useNativeDriver: true,
}),
Animated.timing(scale, {
toValue: 1.1,
duration: config.duration,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: config.duration,
useNativeDriver: true,
}),
]),
Animated.parallel([
Animated.timing(translateY, {
toValue: 0,
duration: config.duration,
useNativeDriver: true,
}),
Animated.timing(scale, {
toValue: 1,
duration: config.duration,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0.6,
duration: config.duration,
useNativeDriver: true,
}),
]),
]),
).start();
};
const t = setTimeout(animate, config.delay);
return () => clearTimeout(t);
}, [config, translateY, scale, opacity]);
return (
<Animated.View
pointerEvents="none"
style={{
position: 'absolute',
width: config.size,
height: config.size,
borderRadius: config.size / 2,
backgroundColor: 'rgba(99, 102, 241, 0.12)',
top: config.top,
bottom: config.bottom,
left: config.left,
right: config.right,
opacity,
transform: [{ translateY }, { scale }],
}}
/>
);
}
export function BrandSplash() {
const { t } = useTranslation();
// Phase-Opacity-Animationen
const containerOpacity = useRef(new Animated.Value(1)).current;
const glowCenterOpacity = useRef(new Animated.Value(0)).current;
const glowCenterScale = useRef(new Animated.Value(0.6)).current;
const glowTopOpacity = useRef(new Animated.Value(0.5)).current;
const nameOpacity = useRef(new Animated.Value(0)).current;
const nameTranslateY = useRef(new Animated.Value(12)).current;
const logoOpacity = useRef(new Animated.Value(0)).current;
const logoScale = useRef(new Animated.Value(0.82)).current;
const logoTranslateY = useRef(new Animated.Value(8)).current;
const logoPulse = useRef(new Animated.Value(1)).current;
const taglineOpacity = useRef(new Animated.Value(0)).current;
const taglineTranslateY = useRef(new Animated.Value(8)).current;
const subOpacity = useRef(new Animated.Value(0)).current;
const subTranslateY = useRef(new Animated.Value(6)).current;
const footerOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
// Top-glow breath loop (4s alternating) — startet sofort
Animated.loop(
Animated.sequence([
Animated.timing(glowTopOpacity, {
toValue: 0.9,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(glowTopOpacity, {
toValue: 0.5,
duration: 2000,
useNativeDriver: true,
}),
]),
).start();
const ease = (toValue: number, duration: number) => ({
toValue,
duration,
useNativeDriver: true,
});
// Phase 1: glow center bloom (T=0)
Animated.parallel([
Animated.timing(glowCenterOpacity, ease(1, 900)),
Animated.timing(glowCenterScale, ease(1, 900)),
]).start();
// Phase 2: Name fade-in (T=300)
setTimeout(() => {
Animated.parallel([
Animated.timing(nameOpacity, ease(1, 600)),
Animated.timing(nameTranslateY, ease(0, 600)),
]).start();
}, T_NAME);
// Phase 3: Logo bouncy scale-in (T=700)
setTimeout(() => {
Animated.parallel([
Animated.timing(logoOpacity, ease(1, 650)),
Animated.spring(logoScale, {
toValue: 1,
useNativeDriver: true,
friction: 6,
tension: 80,
}),
Animated.timing(logoTranslateY, ease(0, 650)),
]).start();
}, T_LOGO);
// Phase 3b: Logo breathing pulse (T=1100)
setTimeout(() => {
Animated.loop(
Animated.sequence([
Animated.timing(logoPulse, {
toValue: 1.04,
duration: 1300,
useNativeDriver: true,
}),
Animated.timing(logoPulse, {
toValue: 1,
duration: 1300,
useNativeDriver: true,
}),
]),
).start();
}, T_PULSE);
// Phase 4: Tagline (T=1300)
setTimeout(() => {
Animated.parallel([
Animated.timing(taglineOpacity, ease(1, 550)),
Animated.timing(taglineTranslateY, ease(0, 550)),
]).start();
}, T_TAGLINE);
// Phase 5: Sub-text + Footer (T=1700)
setTimeout(() => {
Animated.parallel([
Animated.timing(subOpacity, ease(1, 500)),
Animated.timing(subTranslateY, ease(0, 500)),
Animated.timing(footerOpacity, ease(1, 600)),
]).start();
}, T_SUB);
// Phase 7: whole-screen fade-out (T=3200, dauert 500ms)
const fadeOutTimer = setTimeout(() => {
Animated.timing(containerOpacity, {
toValue: 0,
duration: T_LEAVE_DUR,
useNativeDriver: true,
}).start();
}, T_HOLD_END);
return () => clearTimeout(fadeOutTimer);
}, [
glowTopOpacity,
glowCenterOpacity,
glowCenterScale,
nameOpacity,
nameTranslateY,
logoOpacity,
logoScale,
logoTranslateY,
logoPulse,
taglineOpacity,
taglineTranslateY,
subOpacity,
subTranslateY,
footerOpacity,
containerOpacity,
]);
return (
<Animated.View
style={{
flex: 1,
backgroundColor: '#0f172a',
opacity: containerOpacity,
overflow: 'hidden',
}}
>
{/* Top breathing radial-gradient ellipse (#1e3a8a auf transparent) */}
<Animated.View
pointerEvents="none"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: SH * 0.5,
opacity: glowTopOpacity,
}}
>
<Svg width="100%" height="100%">
<Defs>
<RadialGradient
id="topGlow"
cx="50%"
cy="0%"
rx="70%"
ry="100%"
fx="50%"
fy="0%"
>
<Stop offset="0%" stopColor="#1e3a8a" stopOpacity="1" />
<Stop offset="100%" stopColor="#1e3a8a" stopOpacity="0" />
</RadialGradient>
</Defs>
<Rect width="100%" height="100%" fill="url(#topGlow)" />
</Svg>
</Animated.View>
{/* Center indigo halo — bloomt rein wenn Logo erscheint */}
<Animated.View
pointerEvents="none"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: glowCenterOpacity,
transform: [{ scale: glowCenterScale }],
}}
>
<Svg width="100%" height="100%">
<Defs>
<RadialGradient id="centerHalo" cx="50%" cy="52%" rx="55%" ry="55%">
<Stop offset="0%" stopColor="#6366f1" stopOpacity="0.22" />
<Stop offset="100%" stopColor="#6366f1" stopOpacity="0" />
</RadialGradient>
</Defs>
<Rect width="100%" height="100%" fill="url(#centerHalo)" />
</Svg>
</Animated.View>
{/* Floating particles (5 Stück) */}
{PARTICLES.map((p, i) => (
<Particle key={i} config={p} />
))}
{/* Content-Column */}
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 20,
paddingHorizontal: 16,
}}
>
{/* App-Name */}
<Animated.Text
style={{
fontFamily: 'Nunito_800ExtraBold',
fontSize: 48,
letterSpacing: -1,
color: '#ffffff',
textAlign: 'center',
marginBottom: 8,
opacity: nameOpacity,
transform: [{ translateY: nameTranslateY }],
}}
>
{t('appHeader.appName')}
</Animated.Text>
{/* Logo (mit Pulse + Bouncy Entry) */}
<Animated.View
style={{
opacity: logoOpacity,
transform: [
{ scale: Animated.multiply(logoScale, logoPulse) as any },
{ translateY: logoTranslateY },
],
}}
>
<Image
source={require('../assets/icon.png')}
style={{
width: 160,
height: 160,
borderRadius: 28,
}}
resizeMode="contain"
/>
</Animated.View>
{/* Tagline */}
<Animated.Text
style={{
fontFamily: 'Nunito_600SemiBold',
fontSize: 20,
letterSpacing: 0.2,
color: 'rgba(255, 255, 255, 0.90)',
textAlign: 'center',
marginTop: 4,
opacity: taglineOpacity,
transform: [{ translateY: taglineTranslateY }],
}}
>
{t('splash.tagline')}
</Animated.Text>
{/* Sub-text */}
<Animated.Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 14,
letterSpacing: 0.6,
color: 'rgba(255, 255, 255, 0.55)',
textAlign: 'center',
opacity: subOpacity,
transform: [{ translateY: subTranslateY }],
}}
>
{t('splash.subtitle')}
</Animated.Text>
</View>
{/* Footer */}
<Animated.Text
style={{
position: 'absolute',
bottom: 32,
left: 0,
right: 0,
fontFamily: 'Nunito_400Regular',
fontSize: 11,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: 'rgba(255, 255, 255, 0.28)',
textAlign: 'center',
opacity: footerOpacity,
}}
>
{t('splash.madeInGermany')}
</Animated.Text>
</Animated.View>
);
}

View File

@ -0,0 +1,57 @@
import { ActivityIndicator, Pressable, Text } from 'react-native';
import type { PressableProps } from 'react-native';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Props = PressableProps & {
children: React.ReactNode;
variant?: Variant;
loading?: boolean;
disabled?: boolean;
className?: string;
};
const variantStyles: Record<Variant, { container: string; text: string }> = {
primary: {
container: 'bg-rebreak-500 rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-white text-base',
},
secondary: {
container: 'bg-neutral-100 border border-neutral-200 rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-neutral-800 text-base',
},
ghost: {
container: 'rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-neutral-600 text-base',
},
danger: {
container: 'bg-red-50 border border-red-200 rounded-xl px-5 py-3.5 items-center justify-center',
text: 'text-red-600 text-base',
},
};
export function Button({
children,
variant = 'primary',
loading = false,
disabled = false,
className = '',
...rest
}: Props) {
const styles = variantStyles[variant];
const isDisabled = disabled || loading;
return (
<Pressable
className={`${styles.container} ${isDisabled ? 'opacity-50' : ''} ${className}`}
disabled={isDisabled}
{...rest}
>
{loading ? (
<ActivityIndicator color={variant === 'primary' ? '#fff' : '#f59e0b'} size="small" />
) : (
<Text className={styles.text} style={{ fontFamily: 'Nunito_600SemiBold' }}>{children}</Text>
)}
</Pressable>
);
}

View File

@ -0,0 +1,19 @@
import { View } from 'react-native';
import type { ViewProps } from 'react-native';
type Props = ViewProps & {
children: React.ReactNode;
className?: string;
};
export function Card({ children, className = '', style, ...rest }: Props) {
return (
<View
className={`bg-white border border-neutral-200 rounded-2xl p-4 ${className}`}
style={style}
{...rest}
>
{children}
</View>
);
}

View File

@ -0,0 +1,174 @@
import { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
Pressable,
Image,
ActivityIndicator,
Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import * as FileSystem from 'expo-file-system';
import * as ImagePicker from 'expo-image-picker';
import { apiFetch } from '../lib/api';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useAuthStore } from '../stores/auth';
import { colors } from '../lib/theme';
type Props = {
onPosted?: () => void;
};
export function ComposeCard({ onPosted }: Props) {
const { t } = useTranslation();
const { user } = useAuthStore();
const queryClient = useQueryClient();
const inputRef = useRef<TextInput>(null);
const [focused, setFocused] = useState(false);
const [content, setContent] = useState('');
const [imageUri, setImageUri] = useState<string | null>(null);
const [posting, setPosting] = useState(false);
const avatarId = user?.user_metadata?.avatar_id as string | undefined;
const nickname = (user?.user_metadata?.username as string | undefined) ?? t('community.compose_default_user');
const avatarUrl = resolveAvatar(avatarId ?? null, nickname);
const cancel = () => {
setContent('');
setImageUri(null);
setFocused(false);
inputRef.current?.blur();
};
const pickImage = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert(t('community.compose_photo_perm_title'), t('community.compose_photo_perm_desc'));
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
});
if (!result.canceled && result.assets[0]?.uri) {
setImageUri(result.assets[0].uri);
}
};
const submit = async () => {
if (!content.trim() && !imageUri) return;
setPosting(true);
try {
let uploadedImageUrl: string | undefined;
if (imageUri) {
const base64 = await FileSystem.readAsStringAsync(imageUri, {
encoding: FileSystem.EncodingType.Base64,
});
const upload = await apiFetch<{ url: string }>('/api/community/upload-image', {
method: 'POST',
body: {
image: `data:image/jpeg;base64,${base64}`,
mimeType: 'image/jpeg',
},
});
uploadedImageUrl = upload?.url;
}
await apiFetch('/api/community/post', {
method: 'POST',
body: {
category: 'story',
content: content.trim(),
...(uploadedImageUrl ? { imageUrl: uploadedImageUrl } : {}),
},
});
cancel();
queryClient.invalidateQueries({ queryKey: ['community-posts'] });
onPosted?.();
} catch (err: any) {
Alert.alert(t('common.error'), err?.message ?? t('community.post_failed'));
} finally {
setPosting(false);
}
};
const showActions = focused || content.length > 0;
return (
<View className="bg-white border border-neutral-200 rounded-2xl p-4 mb-4">
<View className="flex-row items-start gap-3">
<Image
source={{ uri: avatarUrl }}
className="w-8 h-8 rounded-full bg-neutral-100 shrink-0 mt-0.5"
/>
<View className="flex-1 min-w-0">
<TextInput
ref={inputRef}
value={content}
onChangeText={setContent}
onFocus={() => setFocused(true)}
placeholder={t('community.compose_placeholder')}
placeholderTextColor="#a3a3a3"
multiline
className="text-sm text-neutral-900 leading-5 min-h-[40px]"
style={{ textAlignVertical: 'top', fontFamily: 'Nunito_400Regular' }}
/>
{imageUri && (
<View className="relative mt-2">
<Image
source={{ uri: imageUri }}
className="w-full rounded-xl"
style={{ height: 160 }}
resizeMode="cover"
/>
<Pressable
onPress={() => setImageUri(null)}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 items-center justify-center"
>
<Ionicons name="close" size={14} color="#fff" />
</Pressable>
</View>
)}
</View>
</View>
{showActions && (
<View className="flex-row items-center justify-between mt-3 pt-3 border-t border-neutral-100">
<Pressable
onPress={pickImage}
className="flex-row items-center gap-1.5"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Ionicons name="image-outline" size={18} color="#737373" />
<Text className="text-sm text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>{t('community.image')}</Text>
</Pressable>
<View className="flex-row items-center gap-2">
<Pressable onPress={cancel}>
<Text className="text-sm text-neutral-400" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={submit}
disabled={!content.trim() || posting}
className="h-8 px-4 rounded-full bg-rebreak-500 items-center justify-center flex-row gap-1.5"
style={({ pressed }) => ({
opacity: pressed || !content.trim() || posting ? 0.5 : 1,
})}
>
{posting ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-white text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('community.share')}</Text>
)}
</Pressable>
</View>
</View>
)}
</View>
);
}

View File

@ -0,0 +1,204 @@
import { useEffect, useRef } from 'react';
import { Modal, View, Text, Pressable, Animated, Easing } from 'react-native';
// Wichtig (UX-Entscheidung 2026-05-05): Icon im Confirm-Modal NICHT animieren —
// User sieht zwei Modals nacheinander (Confirm → Success), beide animierte Icons
// = visuelle Doppel-Eskalation, wirkt verwirrend. Daher: Card animiert auf,
// Icon erscheint statisch (kein scale-pop). Nur das nachfolgende SuccessAlert
// behält seine Icon-Animation als "Belohnungs-Moment".
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
type Props = {
visible: boolean;
title: string;
message?: string;
/** Default: i18n "common.cancel" */
cancelLabel?: string;
/** Default: i18n "common.confirm" */
confirmLabel?: string;
/** Wenn true: Confirm-Button rot statt blau (für destructive Actions). */
destructive?: boolean;
/** Icon im Top-Circle. Default: question-mark. */
icon?: React.ComponentProps<typeof Ionicons>['name'];
/** Icon-Circle-Color. Default: #007AFF (iOS-blue). */
iconColor?: string;
onConfirm: () => void;
onCancel: () => void;
};
/**
* Animiertes iOS-style Confirm-Modal gleicher Animations-Stil wie SuccessAlert,
* aber mit zwei Buttons (Cancel + Confirm). Tap-outside cancelt.
*/
export function ConfirmAlert({
visible,
title,
message,
cancelLabel,
confirmLabel,
destructive = false,
icon = 'help-circle',
iconColor = '#007AFF',
onConfirm,
onCancel,
}: Props) {
const { t } = useTranslation();
const resolvedCancelLabel = cancelLabel ?? t('common.cancel');
const resolvedConfirmLabel = confirmLabel ?? t('common.confirm');
const cardScale = useRef(new Animated.Value(0.8)).current;
const cardOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
cardScale.setValue(0.8);
cardOpacity.setValue(0);
Animated.parallel([
Animated.spring(cardScale, {
toValue: 1,
useNativeDriver: true,
friction: 7,
tension: 80,
}),
Animated.timing(cardOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
]).start();
}
}, [visible, cardScale, cardOpacity]);
const confirmBg = destructive ? '#FF3B30' : '#007AFF';
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<Pressable
onPress={onCancel}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
padding: 24,
}}
>
<Pressable onPress={() => {}} style={{ width: '85%', maxWidth: 340 }}>
<Animated.View
style={{
backgroundColor: '#fff',
borderRadius: 22,
padding: 22,
width: '100%',
transform: [{ scale: cardScale }],
opacity: cardOpacity,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.2,
shadowRadius: 24,
elevation: 16,
}}
>
{/* Icon-Circle — statisch (keine Pop-Animation, siehe Header-Comment). */}
<View
style={{
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: iconColor,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 14,
alignSelf: 'center',
shadowColor: iconColor,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 6,
}}
>
<Ionicons name={icon} size={32} color="#fff" />
</View>
<Text
style={{
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center',
marginBottom: 8,
}}
>
{title}
</Text>
{message && (
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#525252',
textAlign: 'center',
lineHeight: 20,
marginBottom: 18,
}}
>
{message}
</Text>
)}
{/* Two buttons row */}
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1, marginRight: 5 }}>
<Pressable
onPress={onCancel}
style={{
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
}}
>
{resolvedCancelLabel}
</Text>
</Pressable>
</View>
<View style={{ flex: 1, marginLeft: 5 }}>
<Pressable
onPress={onConfirm}
style={{
paddingVertical: 10,
borderRadius: 10,
backgroundColor: destructive ? '#fef2f2' : '#eff6ff',
borderWidth: 1,
borderColor: destructive ? '#fecaca' : '#bfdbfe',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: confirmBg,
}}
>
{resolvedConfirmLabel}
</Text>
</Pressable>
</View>
</View>
</Animated.View>
</Pressable>
</Pressable>
</Modal>
);
}

View File

@ -0,0 +1,25 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { ComponentProps } from 'react';
type Props = {
icon: ComponentProps<typeof Ionicons>['name'];
title: string;
subtitle?: string;
children?: React.ReactNode;
};
export function EmptyState({ icon, title, subtitle, children }: Props) {
return (
<View className="flex-1 items-center justify-center py-16 px-6">
<View className="w-16 h-16 rounded-full bg-neutral-100 border border-neutral-200 items-center justify-center mb-4">
<Ionicons name={icon} size={28} color="#a3a3a3" />
</View>
<Text className="text-neutral-900 text-base text-center mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>{title}</Text>
{subtitle ? (
<Text className="text-neutral-500 text-sm text-center leading-5" style={{ fontFamily: 'Nunito_400Regular' }}>{subtitle}</Text>
) : null}
{children ? <View className="mt-5 w-full">{children}</View> : null}
</View>
);
}

View File

@ -0,0 +1,23 @@
// HeroIcons shield-check — Replacement für Ionicons "shield-checkmark"
// (User-Entscheidung 2026-05-05: HeroIcons-Stil gefällt besser für Domain-
// Approved-Indikator). Inline-SVG via react-native-svg, kein Extra-Package.
//
// Quelle: heroicons.com/solid → shield-check (Apache 2.0)
import Svg, { Path } from 'react-native-svg';
type Props = {
size?: number;
color?: string;
};
export function HeroShieldCheck({ size = 18, color = '#22c55e' }: Props) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M12.516 2.17a.75.75 0 0 0-1.032 0 11.209 11.209 0 0 1-7.877 3.08.75.75 0 0 0-.722.515A12.74 12.74 0 0 0 2.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 0 0 0 .374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 0 0 0-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08Zm3.78 7.61a.75.75 0 0 0-1.06-1.06L11 12.94 9.28 11.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.06 0l4.78-4.78Z"
/>
</Svg>
);
}

View File

@ -0,0 +1,31 @@
import { Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { PressableProps } from 'react-native';
import type { ComponentProps } from 'react';
type Props = PressableProps & {
name: ComponentProps<typeof Ionicons>['name'];
size?: number;
color?: string;
className?: string;
badge?: number;
};
export function IconButton({
name,
size = 22,
color = '#0a0a0a',
className = '',
badge,
...rest
}: Props) {
return (
<Pressable
className={`w-10 h-10 rounded-full items-center justify-center ${className}`}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
{...rest}
>
<Ionicons name={name} size={size} color={color} />
</Pressable>
);
}

View File

@ -0,0 +1,152 @@
import {
createNavigatorFactory,
TabRouter,
useNavigationBuilder,
type DefaultNavigatorOptions,
type NavigationProp,
type ParamListBase,
type TabActionHelpers,
type TabNavigationState,
type TabRouterOptions,
} from '@react-navigation/native';
import { withLayoutContext } from 'expo-router';
import type { ImageSourcePropType } from 'react-native';
import TabView, { type AppleIcon } from 'react-native-bottom-tabs';
// Pro-Screen Optionen (kompatibel mit Expo Router's Tabs.Screen API)
export type NativeTabsScreenOptions = {
title?: string;
tabBarIcon?: (props: { focused: boolean }) => AppleIcon | ImageSourcePropType;
tabBarBadge?: string;
// Expo-Router-Konvention: href === null → Screen NICHT in TabBar zeigen
href?: string | null;
};
type NativeTabNavigationEventMap = {
tabPress: { data: undefined; canPreventDefault: true };
tabLongPress: { data: undefined };
};
// Native-spezifische Tab-Layer-Optionen (iOS 26 Glass-Pill, Haptik, etc.)
type NativeOnlyOptions = {
sidebarAdaptable?: boolean;
hapticFeedbackEnabled?: boolean;
disablePageAnimations?: boolean;
scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent';
minimizeBehavior?: 'automatic' | 'onScrollDown' | 'onScrollUp' | 'never';
tabBarActiveTintColor?: string;
tabBarInactiveTintColor?: string;
labeled?: boolean;
tabLabelStyle?: {
fontFamily?: string;
fontWeight?: string;
fontSize?: number;
};
};
type Props = DefaultNavigatorOptions<
ParamListBase,
string | undefined,
TabNavigationState<ParamListBase>,
NativeTabsScreenOptions,
NativeTabNavigationEventMap,
NavigationProp<ParamListBase>
> &
TabRouterOptions &
NativeOnlyOptions;
function NativeTabsNavigator({
id,
initialRouteName,
children,
screenOptions,
layout,
sidebarAdaptable = true,
hapticFeedbackEnabled = true,
disablePageAnimations,
scrollEdgeAppearance,
minimizeBehavior,
tabBarActiveTintColor,
tabBarInactiveTintColor,
labeled = true,
tabLabelStyle,
}: Props) {
const { state, descriptors, navigation, NavigationContent } =
useNavigationBuilder<
TabNavigationState<ParamListBase>,
TabRouterOptions,
TabActionHelpers<ParamListBase>,
NativeTabsScreenOptions,
NativeTabNavigationEventMap
>(TabRouter, {
id,
initialRouteName,
children,
screenOptions,
layout,
});
return (
<NavigationContent>
<TabView
labeled={labeled}
sidebarAdaptable={sidebarAdaptable}
hapticFeedbackEnabled={hapticFeedbackEnabled}
disablePageAnimations={disablePageAnimations}
scrollEdgeAppearance={scrollEdgeAppearance}
minimizeBehavior={minimizeBehavior}
tabBarActiveTintColor={tabBarActiveTintColor}
tabBarInactiveTintColor={tabBarInactiveTintColor}
tabLabelStyle={tabLabelStyle}
navigationState={{
index: state.index,
routes: state.routes.map((route) => {
const options = descriptors[route.key].options;
return {
key: route.key,
title: options.title ?? route.name,
focusedIcon: options.tabBarIcon
? options.tabBarIcon({ focused: true })
: undefined,
unfocusedIcon: options.tabBarIcon
? options.tabBarIcon({ focused: false })
: undefined,
badge: options.tabBarBadge,
hidden: options.href === null,
};
}),
}}
onIndexChange={(index) => {
const route = state.routes[index];
const event = navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!event.defaultPrevented) {
navigation.dispatch({
type: 'NAVIGATE',
payload: { name: route.name, merge: true },
target: state.key,
});
}
}}
onTabLongPress={(index) => {
const route = state.routes[index];
navigation.emit({
type: 'tabLongPress',
target: route.key,
});
}}
renderScene={({ route }) => descriptors[route.key].render()}
/>
</NavigationContent>
);
}
export const createNativeTabNavigator = createNavigatorFactory(NativeTabsNavigator);
const NativeTabNav = createNativeTabNavigator();
// withLayoutContext-wrapped Navigator für Expo-Router-Kompatibilität
export const NativeTabs = withLayoutContext(NativeTabNav.Navigator);

View File

@ -0,0 +1,321 @@
import { useEffect, useRef } from 'react';
import { View, Text, Pressable, Modal, FlatList, Animated, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter, type RelativePathString } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { useNotificationStore, type AppNotification } from '../stores/notifications';
import { resolveAvatar } from '../lib/resolveAvatar';
import { HeroShieldCheck } from './HeroShieldCheck';
type Props = {
visible: boolean;
onClose: () => void;
/** Distanz vom oberen Rand bis Dropdown anchor (Header-Höhe inkl. SafeArea) */
topOffset: number;
};
export function NotificationsDropdown({ visible, onClose, topOffset }: Props) {
const { t } = useTranslation();
const router = useRouter();
const items = useNotificationStore((s) => s.items);
const loaded = useNotificationStore((s) => s.loaded);
const load = useNotificationStore((s) => s.load);
const markRead = useNotificationStore((s) => s.markRead);
const unread = useNotificationStore((s) => s.unread);
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current;
useEffect(() => {
if (visible) {
if (!loaded) load();
// Mark as read with delay so user sees the unread highlight briefly
const tm = setTimeout(() => {
if (unread > 0) markRead();
}, 600);
Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration: 140, useNativeDriver: true }),
Animated.timing(translateY, { toValue: 0, duration: 160, useNativeDriver: true }),
]).start();
return () => clearTimeout(tm);
}
opacity.setValue(0);
translateY.setValue(-8);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
function handleNavigate(n: AppNotification) {
onClose();
if (n.type === 'domain_accepted' || n.type === 'domain_rejected') {
router.push('/blocker' as RelativePathString);
} else if (n.postId) {
router.push(`/?postId=${n.postId}` as RelativePathString);
}
}
return (
<Modal
visible={visible}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<Pressable
onPress={onClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
>
<Animated.View
onStartShouldSetResponder={() => true}
style={{
position: 'absolute',
top: topOffset + 6,
right: 12,
backgroundColor: '#ffffff',
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
width: 320,
maxHeight: 480,
overflow: 'hidden',
opacity,
transform: [{ translateY }],
}}
>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
<Text
style={{ flex: 1, fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
>
{t('notifications.title')}
</Text>
{unread > 0 && (
<Pressable onPress={() => markRead()} hitSlop={6}>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#007AFF' }}
>
{t('notifications.mark_all_read')}
</Text>
</Pressable>
)}
</View>
{items.length === 0 ? (
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
<Ionicons name="notifications-off-outline" size={28} color="#a3a3a3" />
<Text
style={{
marginTop: 8,
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
}}
>
{t('notifications.empty_title')}
</Text>
<Text
style={{
marginTop: 2,
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
textAlign: 'center',
paddingHorizontal: 20,
}}
>
{t('notifications.empty_subtitle')}
</Text>
</View>
) : (
<FlatList
data={items}
keyExtractor={(n) => n.id}
renderItem={({ item }) => (
<NotificationRow
notif={item}
onPress={() => handleNavigate(item)}
t={t}
/>
)}
/>
)}
</Animated.View>
</Pressable>
</Modal>
);
}
function notifLabel(n: AppNotification, t: (k: string, opts?: any) => string): string {
switch (n.type) {
case 'new_like':
return `${n.actorName} ${t('notifications.liked_post')}`;
case 'new_comment':
return `${n.actorName} ${t('notifications.commented_post')}`;
case 'domain_vote':
return `${n.actorName} ${t('notifications.voted_domain')}`;
case 'domain_accepted':
return n.preview
? `${n.preview} ${t('notifications.domain_accepted')}`
: t('notifications.domain_accepted');
case 'domain_rejected':
return n.preview
? `${n.preview} ${t('notifications.domain_rejected')}`
: t('notifications.domain_rejected');
case 'new_follower':
return `${n.actorName} ${t('notifications.new_follower')}`;
default:
return `${n.actorName} ${t('notifications.generic')}`;
}
}
function notifIcon(type: string): {
icon: React.ComponentProps<typeof Ionicons>['name'];
color: string;
bg: string;
} {
switch (type) {
case 'new_like':
return { icon: 'heart', color: '#dc2626', bg: '#fee2e2' };
case 'new_comment':
return { icon: 'chatbubble-ellipses', color: '#2563eb', bg: '#dbeafe' };
case 'domain_accepted':
return { icon: 'shield-checkmark', color: '#16a34a', bg: '#dcfce7' };
case 'domain_rejected':
return { icon: 'close-circle', color: '#dc2626', bg: '#fee2e2' };
case 'domain_vote':
return { icon: 'thumbs-up', color: '#d97706', bg: '#fef3c7' };
case 'new_follower':
return { icon: 'person-add', color: '#7c3aed', bg: '#ede9fe' };
default:
return { icon: 'notifications', color: '#737373', bg: '#f5f5f5' };
}
}
function timeAgo(dateStr: string, t: (k: string, opts?: any) => string): string {
const m = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
if (m < 1) return t('notifications.just_now');
if (m < 60) return t('notifications.min_ago', { n: m });
const h = Math.floor(m / 60);
if (h < 24) return t('notifications.hours_ago', { n: h });
return t('notifications.days_ago', { n: Math.floor(h / 24) });
}
function NotificationRow({
notif,
onPress,
t,
}: {
notif: AppNotification;
onPress: () => void;
t: (k: string, opts?: any) => string;
}) {
const isUnread = !notif.readAt;
const { icon, color, bg } = notifIcon(notif.type);
const isSocial =
notif.type === 'new_like' ||
notif.type === 'new_comment' ||
notif.type === 'new_follower' ||
notif.type === 'domain_vote';
// System-Notifications (von ReBreak selbst) bekommen das App-Icon als Avatar
const isSystem =
notif.type === 'domain_accepted' ||
notif.type === 'domain_rejected' ||
(notif.actorName ?? '').toLowerCase().startsWith('rebreak');
const avatarUrl = isSocial ? resolveAvatar(notif.actorAvatar, notif.actorName) : null;
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
opacity: pressed ? 0.65 : 1,
backgroundColor: isUnread ? '#fff7ed' : '#ffffff',
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 14,
paddingVertical: 11,
borderBottomWidth: 1,
borderBottomColor: '#f5f5f5',
}}
>
{/* Avatar-Logik:
- Social: User-Avatar mit kleinem Type-Badge
- System (ReBreak): App-Icon mit kleinem Type-Badge
- Sonst: Typed Icon */}
{avatarUrl ? (
// Social: Avatar mit kleinem Mini-Badge — Badge ohne weißen Ring (clean).
<View style={{ marginRight: 10, position: 'relative' }}>
<Image
source={{ uri: avatarUrl }}
style={{ width: 32, height: 32, borderRadius: 16, backgroundColor: '#f5f5f5' }}
/>
<View
style={{
position: 'absolute',
bottom: -2,
right: -2,
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: bg,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={9} color={color} />
</View>
</View>
) : (
// System (domain_accepted/rejected/etc.) + Fallback: NUR clean Icon,
// kein Avatar-Overlay mehr — vorher hatte ReBreak-App-Icon mit
// Shield-Badge-Overlay den Logo verdeckt (User-Feedback 2026-05-05).
<View style={{ marginRight: 12, alignItems: 'center', justifyContent: 'center', width: 32 }}>
{notif.type === 'domain_accepted' ? (
<HeroShieldCheck size={24} color={color} />
) : (
<Ionicons name={icon} size={24} color={color} />
)}
</View>
)}
<View style={{ flex: 1, minWidth: 0 }}>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
lineHeight: 16,
}}
numberOfLines={2}
>
{notifLabel(notif, t)}
</Text>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 2,
}}
>
{timeAgo(notif.createdAt, t)}
</Text>
</View>
</View>
</Pressable>
);
}

View File

@ -0,0 +1,558 @@
import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { View, Text, Pressable, Image, Animated } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { resolveAvatar } from '../lib/resolveAvatar';
import { formatRelativeTime } from '../lib/formatTime';
import { useCommunityStore, type CommunityPost } from '../stores/community';
import { RiveAvatar } from './RiveAvatar';
import { HeroShieldCheck } from './HeroShieldCheck';
type Props = {
post: CommunityPost;
onCommentPress: (postId: string) => void;
};
function PostCardImpl({ post, onCommentPress }: Props) {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Granular selectors — subscribing to the whole store would re-render every
// PostCard whenever any user likes any post (optimisticLikes mutates).
const applyOptimisticLike = useCommunityStore((s) => s.applyOptimisticLike);
const revertOptimisticLike = useCommunityStore((s) => s.revertOptimisticLike);
const clearOptimisticLike = useCommunityStore((s) => s.clearOptimisticLike);
const [localLike, setLocalLike] = useState<'like' | null>(post.userLike === 'like' ? 'like' : null);
const [localCount, setLocalCount] = useState(post.likesCount);
const [isLiking, setIsLiking] = useState(false);
// Heart-Pop Animation — Insta-Style: quick scale-up + spring-bounce back
const heartScale = useRef(new Animated.Value(1)).current;
const triggerHeartPop = useCallback(() => {
heartScale.setValue(1);
Animated.sequence([
Animated.timing(heartScale, {
toValue: 1.4,
duration: 120,
useNativeDriver: true,
}),
Animated.spring(heartScale, {
toValue: 1,
friction: 4,
tension: 80,
useNativeDriver: true,
}),
]).start();
}, [heartScale]);
const displayAuthor = post.repostOf ? post.repostOf.author : post.author;
const displayContent = post.repostOf ? post.repostOf.content : post.content;
const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl;
// Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}.
// Fallback während loading = 1.78 (16:9). Clamp 0.6..1.78 verhindert,
// dass hyper-portrait-Bilder (9:21) den ganzen Screen einnehmen.
const [imageAspectRatio, setImageAspectRatio] = useState<number | null>(null);
// Reset bei post-change (FlatList-Recycling).
useEffect(() => {
setImageAspectRatio(null);
}, [displayImage]);
const authorLabel = post.isAnonymous || !displayAuthor.id ? t('community.anonymous_label') : displayAuthor.nickname;
// Lyra bot posts use the RiveAvatar (sm = 40px circle). All other bots and
// regular users use the image/initials fallback path.
const isLyraPost = post.isBot && post.botType === 'lyra';
// Avatar: only render Image if author has avatar id; resolveAvatar returns the URL.
// On image-load error or missing avatar id → initials fallback.
const hasAvatar = !!displayAuthor.avatar && !post.isAnonymous && !isLyraPost;
const avatarUrl = hasAvatar ? resolveAvatar(displayAuthor.avatar, displayAuthor.nickname) : '';
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
// Reset error-state when post (or its avatar) changes — list-virtualization may reuse component.
useEffect(() => {
setAvatarLoadFailed(false);
}, [avatarUrl]);
const showAvatarImage = hasAvatar && !avatarLoadFailed;
const avatarInitials = (
authorLabel.charAt(0) + (authorLabel.charAt(1) ?? '')
).toUpperCase() || '?';
// domain_approved: extract domain name from Google favicon URL stored in imageUrl
const approvedDomain = (() => {
if (post.category !== 'domain_approved' || !displayImage) return null;
try { return new URL(displayImage).searchParams.get('domain'); } catch { return null; }
})();
// domain_vote vote action — delegate up via apiFetch, no local store mutation needed
// (realtime hook invalidates query on submission UPDATE)
const [voting, setVoting] = useState(false);
const [localVote, setLocalVote] = useState<'yes' | 'no' | null>(post.userVote ?? null);
const [localYes, setLocalYes] = useState(post.submission?.yesVotes ?? 0);
const [localNo, setLocalNo] = useState(post.submission?.noVotes ?? 0);
useEffect(() => {
setLocalVote(post.userVote ?? null);
setLocalYes(post.submission?.yesVotes ?? 0);
setLocalNo(post.submission?.noVotes ?? 0);
}, [post.userVote, post.submission?.yesVotes, post.submission?.noVotes]);
const handleVote = useCallback(async (vote: 'yes' | 'no') => {
if (voting || !post.submission?.id || localVote) return;
setVoting(true);
setLocalVote(vote);
if (vote === 'yes') setLocalYes((n) => n + 1);
else setLocalNo((n) => n + 1);
try {
const res = await apiFetch<{ yesVotes: number; noVotes: number; movedToReview: boolean }>(
`/api/domain-submissions/${post.submission.id}/vote`,
{ method: 'POST', body: { vote } },
);
setLocalYes(res.yesVotes);
setLocalNo(res.noVotes);
queryClient.invalidateQueries({ queryKey: ['community-posts'] });
} catch {
setLocalVote(null);
if (vote === 'yes') setLocalYes((n) => Math.max(0, n - 1));
else setLocalNo((n) => Math.max(0, n - 1));
} finally {
setVoting(false);
}
}, [voting, localVote, post.submission?.id, queryClient]);
const authorDescription = (() => {
if (post.isBot) return post.botType === 'rebreak' ? t('community.bot_admin') : t('community.bot_ai');
if (post.isAnonymous || !displayAuthor.id) return undefined;
const plan = displayAuthor.plan;
if (plan === 'legend') return t('community.tier_legend');
if (plan === 'pro') return t('community.tier_pro');
return t('community.tier_starter');
})();
const handleLike = useCallback(async () => {
if (isLiking) return;
triggerHeartPop();
const { newLike, newCount } = applyOptimisticLike(post.id, localLike, localCount);
setLocalLike(newLike);
setLocalCount(newCount);
setIsLiking(true);
try {
const res = await apiFetch<{
likesCount: number;
dislikesCount: number;
userLike: 'like' | 'dislike' | null;
}>('/api/community/like', {
method: 'POST',
body: { postId: post.id, type: 'like' },
});
setLocalCount(res.likesCount);
setLocalLike(res.userLike === 'like' ? 'like' : null);
clearOptimisticLike(post.id);
// KEIN queryClient.invalidateQueries — würde die komplette Liste neu laden,
// PostCard remounted, Heart-Pop-Animation abgebrochen. Local-State reicht.
} catch {
revertOptimisticLike(post.id);
setLocalLike(post.userLike === 'like' ? 'like' : null);
setLocalCount(post.likesCount);
} finally {
setIsLiking(false);
}
}, [isLiking, localLike, localCount, post.id, post.userLike, post.likesCount, applyOptimisticLike, clearOptimisticLike, revertOptimisticLike, queryClient, triggerHeartPop]);
return (
<View className="bg-white border border-neutral-200 rounded-2xl p-3 mb-3">
{/* Repost header */}
{post.repostOf && (
<View className="flex-row items-center gap-1.5 mb-3">
<Ionicons name="repeat" size={14} color="#737373" />
<Text className="text-xs text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>
{post.author.nickname} {t('community.reposted_suffix')}
</Text>
</View>
)}
{/* Author + Meta */}
<View className="flex-row items-start justify-between mb-2">
<View className="flex-row items-center gap-2.5 flex-1">
{isLyraPost ? (
// Lyra bot posts use the animated Rive avatar at sm (40px).
// The RiveAvatar sm-variant has no border/shadow by design — fits tight in list.
<RiveAvatar emotion="idle" size="sm" />
) : showAvatarImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setAvatarLoadFailed(true)}
className="w-10 h-10 rounded-full bg-neutral-100"
/>
) : (
<View className="w-10 h-10 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>
{avatarInitials}
</Text>
</View>
)}
<View className="flex-1 min-w-0">
<Text className="text-sm text-neutral-900" numberOfLines={1} style={{ fontFamily: 'Nunito_600SemiBold' }}>
{authorLabel}
</Text>
{authorDescription !== undefined && (
<Text className="text-xs text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>{authorDescription}</Text>
)}
</View>
</View>
<Text className="text-xs text-neutral-400 shrink-0 ml-2 mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>
{formatRelativeTime(post.createdAt)}
</Text>
</View>
{/* Content — hidden for domain_vote (replaced by poll below) */}
{!!displayContent && post.category !== 'domain_vote' && (
<Text className="text-sm text-neutral-800 leading-relaxed mb-0" style={{ fontFamily: 'Nunito_400Regular' }}>
{displayContent}
</Text>
)}
{/* domain_approved: favicon + domain name + shield badge */}
{post.category === 'domain_approved' && !!approvedDomain && (
<View className="flex-row items-center gap-2.5 mt-3 rounded-xl border border-neutral-200 bg-neutral-50 px-3 py-2">
<DomainFavicon domain={approvedDomain} size={24} />
<View className="flex-1 min-w-0">
<Text className="text-xs font-semibold text-neutral-900 truncate" style={{ fontFamily: 'Nunito_700Bold' }}>
{approvedDomain}
</Text>
<Text className="text-xs text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.domain_added_to_blocklist')}
</Text>
</View>
<HeroShieldCheck size={18} color="#22c55e" />
</View>
)}
{/* domain_vote: poll card with domain banner + yes/no bars + vote buttons */}
{post.category === 'domain_vote' && !!post.submission && (
<DomainVoteCard
submission={post.submission}
localYes={localYes}
localNo={localNo}
localVote={localVote}
voting={voting}
isOwnPost={post.author.id === null}
onVote={handleVote}
t={t}
/>
)}
{/* Image respektiert echtes Aspect-Ratio (portrait/square/landscape),
clamped 0.6..1.78 damit 9:21-Storys nicht den ganzen Screen einnehmen. */}
{!!displayImage && post.category !== 'domain_approved' && post.category !== 'domain_vote' && (
<Image
source={{ uri: displayImage }}
onLoad={(e) => {
const { width, height } = e.nativeEvent.source;
if (width && height) {
const ratio = width / height;
setImageAspectRatio(Math.max(0.6, Math.min(1.78, ratio)));
}
}}
className="w-full rounded-xl mt-3"
style={{ aspectRatio: imageAspectRatio ?? 1.78 }}
resizeMode="cover"
/>
)}
{/* Actions: Like, Comment — not shown for domain_vote */}
{/* HitSlop +12pt rundum → effektiver Touch-Bereich ~44pt (HIG-Min). */}
{/* Vorher: Tap-Area = nur Icon-Größe (~21pt) → User-Feedback "reagiert nicht beim 1. Klick". */}
{post.category !== 'domain_vote' && (
<View className="flex-row items-center gap-6 mt-3 py-1">
<Pressable
onPress={handleLike}
disabled={isLiking}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
android_ripple={{ color: 'rgba(220,38,38,0.12)', borderless: true, radius: 22 }}
className="flex-row items-center gap-1.5"
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, transform: [{ scale: pressed ? 0.94 : 1 }] })}
>
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
<Ionicons
name={localLike === 'like' ? 'heart' : 'heart-outline'}
size={20}
color={localLike === 'like' ? '#dc2626' : '#737373'}
/>
</Animated.View>
{localCount > 0 && (
<Text className="text-xs text-neutral-600" style={{ fontFamily: 'Nunito_600SemiBold' }}>{localCount}</Text>
)}
</Pressable>
<Pressable
onPress={() => onCommentPress(post.id)}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }}
className="flex-row items-center gap-1.5"
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, transform: [{ scale: pressed ? 0.94 : 1 }] })}
>
<Ionicons name="chatbubble-outline" size={19} color="#737373" />
{post.commentsCount > 0 && (
<Text className="text-xs text-neutral-600" style={{ fontFamily: 'Nunito_600SemiBold' }}>{post.commentsCount}</Text>
)}
</Pressable>
</View>
)}
</View>
);
}
// React.memo with a shallow compare on the fields that actually drive visible
// content. Without this, every realtime patch (likes/comments/domain-vote)
// re-mapping the posts array would re-render every visible PostCard, even
// though only one item changed. onCommentPress is expected stable from parent.
export const PostCard = memo(PostCardImpl, (prev, next) => {
if (prev.onCommentPress !== next.onCommentPress) return false;
const a = prev.post;
const b = next.post;
if (a === b) return true;
if (a.id !== b.id) return false;
if (a.likesCount !== b.likesCount) return false;
if (a.dislikesCount !== b.dislikesCount) return false;
if (a.commentsCount !== b.commentsCount) return false;
if (a.repostsCount !== b.repostsCount) return false;
if (a.userLike !== b.userLike) return false;
if (a.userVote !== b.userVote) return false;
if (a.content !== b.content) return false;
if (a.imageUrl !== b.imageUrl) return false;
if (a.category !== b.category) return false;
if (a.isAnonymous !== b.isAnonymous) return false;
if (a.challengeStatus !== b.challengeStatus) return false;
if (a.isLive !== b.isLive) return false;
// submission shallow compare on the fields the card reads
const sa = a.submission;
const sb = b.submission;
if (!!sa !== !!sb) return false;
if (sa && sb) {
if (sa.id !== sb.id) return false;
if (sa.status !== sb.status) return false;
if (sa.yesVotes !== sb.yesVotes) return false;
if (sa.noVotes !== sb.noVotes) return false;
if (sa.reviewedAt !== sb.reviewedAt) return false;
}
// author identity (anonymous toggling, avatar swap)
if (a.author.id !== b.author.id) return false;
if (a.author.avatar !== b.author.avatar) return false;
if (a.author.nickname !== b.author.nickname) return false;
if (a.author.plan !== b.author.plan) return false;
// repostOf identity
const ra = a.repostOf;
const rb = b.repostOf;
if (!!ra !== !!rb) return false;
if (ra && rb) {
if (ra.content !== rb.content) return false;
if (ra.imageUrl !== rb.imageUrl) return false;
if (ra.author.id !== rb.author.id) return false;
if (ra.author.nickname !== rb.author.nickname) return false;
if (ra.author.avatar !== rb.author.avatar) return false;
}
return true;
});
// ── Domain Favicon ─────────────────────────────────────────────────────────
// Google S2 favicon-API as in Nuxt PostCard — on error: letter-avatar fallback.
type DomainFaviconProps = { domain: string; size: number };
function DomainFavicon({ domain, size }: DomainFaviconProps) {
const [failed, setFailed] = useState(false);
const uri = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
const letter = domain.charAt(0).toUpperCase();
if (failed) {
return (
<View
style={{ width: size, height: size, borderRadius: 6, backgroundColor: '#e5e7eb' }}
className="items-center justify-center"
>
<Text style={{ fontSize: size * 0.5, color: '#6b7280', fontFamily: 'Nunito_700Bold' }}>
{letter}
</Text>
</View>
);
}
return (
<Image
source={{ uri }}
style={{ width: size, height: size, borderRadius: 6 }}
onError={() => setFailed(true)}
/>
);
}
// ── Domain Vote Poll Card ───────────────────────────────────────────────────
type Submission = NonNullable<CommunityPost['submission']>;
type DomainVoteCardProps = {
submission: Submission;
localYes: number;
localNo: number;
localVote: 'yes' | 'no' | null;
voting: boolean;
isOwnPost: boolean;
onVote: (v: 'yes' | 'no') => void;
// TFunction from react-i18next has a complex overload signature; using
// a simple callable type avoids generics noise here.
t: (key: string) => string;
};
function DomainVoteCard({
submission,
localYes,
localNo,
localVote,
voting,
isOwnPost,
onVote,
t,
}: DomainVoteCardProps) {
const total = localYes + localNo;
const yesWidth = total === 0 ? 0 : Math.round((localYes / 10) * 100);
// No-bar is relative to yes + no total to mirror Nuxt logic
const noWidth = total === 0 ? 0 : Math.round((localNo / Math.max(total, localYes)) * 100);
const isPending = submission.status === 'pending' || submission.status === 'in_review';
const isApproved = submission.status === 'approved';
const statusLabel = (() => {
if (isApproved) return submission.reviewedAt ? `Approved ${formatApprovedDate(submission.reviewedAt)}` : 'Global';
if (submission.status === 'rejected') return t('community.vote_rejected');
if (submission.status === 'in_review') return t('community.vote_in_review');
return `${localYes} / 10`;
})();
return (
<View className="mt-1 gap-2.5">
{/* Header: label + status badge */}
<View className="flex-row items-center justify-between gap-2">
<View className="flex-row items-center gap-1.5">
<Ionicons name="shield-checkmark-outline" size={14} color="#737373" />
<Text className="text-xs text-neutral-500" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.domain_proposal_label')}
</Text>
</View>
<View
className={`px-2 py-0.5 rounded-full ${isApproved ? 'bg-green-100' : 'bg-neutral-100'}`}
>
<Text
className={`text-[10px] ${isApproved ? 'text-green-700' : 'text-neutral-500'}`}
style={{ fontFamily: 'Nunito_600SemiBold' }}
>
{statusLabel}
</Text>
</View>
</View>
{/* Domain card */}
<View className="flex-row items-center gap-3 rounded-xl px-3 py-2.5 border border-neutral-200 bg-neutral-50">
<DomainFavicon domain={submission.domain} size={28} />
<View className="flex-1 min-w-0">
<Text className="text-sm font-semibold text-neutral-900 truncate" style={{ fontFamily: 'Nunito_700Bold' }}>
{submission.domain}
</Text>
<Text className="text-[10px] text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
{isApproved ? t('community.domain_added') : t('community.domain_proposed')}
</Text>
</View>
<Ionicons
name={isApproved ? 'shield-checkmark' : 'shield-half-outline'}
size={20}
color="#a3a3a3"
/>
</View>
{/* Yes bar */}
<View>
<View className="flex-row items-center justify-between mb-1">
<View className="flex-row items-center gap-1">
<Ionicons name="thumbs-up-outline" size={12} color="#525252" />
<Text className="text-[11px] text-neutral-700" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_yes')}
</Text>
</View>
<Text className="text-[11px] text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
{localYes} / 10
</Text>
</View>
<View className="h-1.5 bg-neutral-100 rounded-full overflow-hidden">
<View className="h-full bg-rebreak-500 rounded-full" style={{ width: `${yesWidth}%` }} />
</View>
</View>
{/* No bar */}
<View>
<View className="flex-row items-center justify-between mb-1">
<View className="flex-row items-center gap-1">
<Ionicons name="thumbs-down-outline" size={12} color="#525252" />
<Text className="text-[11px] text-neutral-700" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_no')}
</Text>
</View>
<Text className="text-[11px] text-neutral-400" style={{ fontFamily: 'Nunito_400Regular' }}>
{localNo}
</Text>
</View>
<View className="h-1.5 bg-neutral-100 rounded-full overflow-hidden">
<View className="h-full bg-red-400 rounded-full" style={{ width: `${noWidth}%` }} />
</View>
</View>
{/* Vote buttons — only for pending + not own post + not already voted */}
{isPending && !isOwnPost && !localVote && (
<View className="flex-row gap-2 pt-0.5">
<Pressable
onPress={() => onVote('yes')}
disabled={voting}
className="flex-1 flex-row items-center justify-center gap-1.5 h-9 rounded-xl border border-rebreak-500"
style={({ pressed }) => ({ opacity: pressed || voting ? 0.5 : 1 })}
>
<Ionicons name="thumbs-up" size={14} color="#f97316" />
<Text className="text-sm text-rebreak-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_yes')}
</Text>
</Pressable>
<Pressable
onPress={() => onVote('no')}
disabled={voting}
className="flex-1 flex-row items-center justify-center gap-1.5 h-9 rounded-xl border border-neutral-300"
style={({ pressed }) => ({ opacity: pressed || voting ? 0.5 : 1 })}
>
<Ionicons name="thumbs-down" size={14} color="#737373" />
<Text className="text-sm text-neutral-500" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('community.vote_no')}
</Text>
</Pressable>
</View>
)}
{/* Already voted indicator */}
{isPending && !isOwnPost && !!localVote && (
<Text className="text-[11px] text-center text-neutral-400 pt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.voted_thanks')}
</Text>
)}
{/* Own post indicator */}
{isPending && isOwnPost && (
<Text className="text-[11px] text-center text-neutral-400 pt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('community.domain_vote_own')}
</Text>
)}
</View>
);
}
function formatApprovedDate(dateStr: string): string {
try {
const d = new Date(dateStr);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch { return ''; }
}

View File

@ -0,0 +1,18 @@
import { View } from 'react-native';
export function PostCardSkeleton() {
return (
<View className="bg-white border border-neutral-200 rounded-2xl p-4 mb-3">
<View className="flex-row items-center gap-3 mb-3">
<View className="w-9 h-9 rounded-full bg-neutral-200" />
<View className="flex-1 gap-1.5">
<View className="h-3 bg-neutral-200 rounded w-1/3" />
<View className="h-2.5 bg-neutral-100 rounded w-1/4" />
</View>
</View>
<View className="h-3 bg-neutral-200 rounded w-full mb-2" />
<View className="h-3 bg-neutral-200 rounded w-3/4 mb-2" />
<View className="h-3 bg-neutral-100 rounded w-1/2" />
</View>
);
}

View File

@ -0,0 +1,503 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import {
View,
Text,
Modal,
FlatList,
TextInput,
Pressable,
Keyboard,
Platform,
ActivityIndicator,
Animated,
PanResponder,
useWindowDimensions,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../lib/api';
import { formatRelativeTime } from '../lib/formatTime';
import { colors } from '../lib/theme';
import type { CommunityComment } from '../stores/community';
const EMOJIS = ['❤️', '🙌', '🔥', '👏', '😢', '😍', '😮', '😂'];
const SNAP_THRESHOLD = 50;
type Props = {
postId: string | null;
visible: boolean;
onClose: () => void;
};
export function PostCommentsSheet({ postId, visible, onClose }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const queryClient = useQueryClient();
const inputRef = useRef<TextInput>(null);
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [replyTarget, setReplyTarget] = useState<{ id: string; nickname: string } | null>(null);
const [keyboardHeight, setKeyboardHeight] = useState(0);
// useWindowDimensions: live-tracking. Auf Android schrumpft `height` wenn die
// Tastatur aufgeht (windowSoftInputMode=adjustResize) — daher dynamisch statt
// `Dimensions.get` (statisch beim Modul-Load).
const { height: SCREEN_HEIGHT } = useWindowDimensions();
const COLLAPSED_HEIGHT = SCREEN_HEIGHT * 0.65;
const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.92;
const MIN_HEIGHT = SCREEN_HEIGHT * 0.35;
// Sheet-Höhe animiert (height-based, bottom: 0 fix → Input bleibt immer am Edge sichtbar).
// Plus separater translateY für die Dismiss-Slide-Animation (native).
const sheetHeight = useRef(new Animated.Value(COLLAPSED_HEIGHT)).current;
const dismissY = useRef(new Animated.Value(0)).current;
const currentHeight = useRef(COLLAPSED_HEIGHT);
const handleClose = useCallback(() => {
Keyboard.dismiss();
setText('');
setReplyTarget(null);
sheetHeight.setValue(COLLAPSED_HEIGHT);
dismissY.setValue(0);
currentHeight.current = COLLAPSED_HEIGHT;
onClose();
}, [onClose, sheetHeight, dismissY]);
useEffect(() => {
if (visible) {
sheetHeight.setValue(COLLAPSED_HEIGHT);
dismissY.setValue(0);
currentHeight.current = COLLAPSED_HEIGHT;
}
}, [visible, sheetHeight, dismissY]);
const panResponder = useRef(
PanResponder.create({
// Claim Gesture sofort, kein Wartet-bis-5px
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderMove: (_, g) => {
// Drag rauf (g.dy < 0) → height grösser. Drag runter → height kleiner.
const next = currentHeight.current - g.dy;
const clamped = Math.max(MIN_HEIGHT - 100, Math.min(EXPANDED_HEIGHT + 20, next));
sheetHeight.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const finalH = currentHeight.current - g.dy;
const velocity = g.vy; // Pixel pro ms (negativ = nach oben, positiv = nach unten)
// Unter MIN_HEIGHT oder schneller Flick nach unten → dismiss
if (finalH < MIN_HEIGHT || velocity > 1.5) {
Animated.timing(dismissY, {
toValue: SCREEN_HEIGHT,
duration: 200,
useNativeDriver: true,
}).start(() => {
handleClose();
});
return;
}
// Schneller Flick nach oben → auf Maximum schnappen
let target = finalH;
if (velocity < -1.5) {
target = EXPANDED_HEIGHT;
}
// Clamp auf gültigen Bereich, sonst bleibt's wo der User losgelassen hat
const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target));
Animated.spring(sheetHeight, {
toValue: clamped,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
currentHeight.current = clamped;
},
}),
).current;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
setKeyboardHeight(e.endCoordinates.height);
});
const hideSub = Keyboard.addListener(hideEvent, () => {
setKeyboardHeight(0);
});
return () => {
showSub.remove();
hideSub.remove();
};
}, []);
const { data: comments = [], isLoading } = useQuery<CommunityComment[]>({
queryKey: ['post-comments', postId],
queryFn: () => apiFetch(`/api/community/${postId}/comments`),
enabled: !!postId && visible,
staleTime: 30_000,
});
const topLevel = comments.filter((c) => !c.parentCommentId);
const repliesFor = (id: string) => comments.filter((c) => c.parentCommentId === id);
const submit = useCallback(async () => {
if (!text.trim() || !postId) return;
setSubmitting(true);
try {
await apiFetch('/api/community/comment', {
method: 'POST',
body: {
postId,
content: text.trim(),
...(replyTarget ? { parentCommentId: replyTarget.id } : {}),
},
});
setText('');
setReplyTarget(null);
queryClient.invalidateQueries({ queryKey: ['post-comments', postId] });
queryClient.invalidateQueries({ queryKey: ['community-posts'] });
} catch {
// ignore
} finally {
setSubmitting(false);
}
}, [text, postId, replyTarget, queryClient]);
const likeComment = useCallback(
async (comment: CommunityComment) => {
try {
await apiFetch('/api/community/comment-like', {
method: 'POST',
body: { commentId: comment.id },
});
queryClient.invalidateQueries({ queryKey: ['post-comments', postId] });
} catch {
// ignore
}
},
[postId, queryClient],
);
// Bei offener Tastatur → automatisch expanded
useEffect(() => {
if (keyboardHeight > 0 && currentHeight.current !== EXPANDED_HEIGHT) {
Animated.spring(sheetHeight, {
toValue: EXPANDED_HEIGHT,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
currentHeight.current = EXPANDED_HEIGHT;
}
}, [keyboardHeight, sheetHeight]);
return (
<Modal
visible={visible}
onRequestClose={handleClose}
animationType="slide"
transparent
statusBarTranslucent
>
{/* Backdrop — sehr leichter Dim damit man Posts vom Drawer unterscheidet */}
<Pressable
onPress={handleClose}
style={{
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.12)',
}}
/>
{/* Outer: animated height (non-native driver) */}
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: sheetHeight,
}}
>
{/* Inner: animated transform (native driver) — getrennt damit kein Driver-Mix */}
<Animated.View
style={{
flex: 1,
backgroundColor: '#ffffff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
// Android: windowSoftInputMode=adjustResize schrumpft schon das Window
// → KEIN paddingBottom mehr (sonst doppelter Offset, Drawer schießt nach oben).
// iOS: kein adjustResize-Equivalent, padding muss hier kompensieren.
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
}}
>
{/* Drag-Bar — drag-down dismisst via PanResponder */}
<View
{...panResponder.panHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View
style={{
width: 36,
height: 5,
borderRadius: 3,
backgroundColor: '#d4d4d8',
}}
/>
</View>
{/* Header — auch drag-area, kein X-Button */}
<View
{...panResponder.panHandlers}
style={{
paddingHorizontal: 20,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#e5e5e5',
}}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('community.comments_title')}
</Text>
</View>
{/* Comments-Liste */}
{isLoading ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : (
<FlatList
data={topLevel}
keyExtractor={(item) => item.id}
style={{ flex: 1 }}
contentContainerStyle={{ paddingVertical: 8, paddingHorizontal: 16 }}
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
<View style={{ alignItems: 'center', paddingVertical: 48 }}>
<Text style={{ fontSize: 14, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{t('community.comments_empty')}
</Text>
</View>
}
renderItem={({ item: comment }) => (
<View>
<CommentRow
comment={comment}
onReply={() => {
setReplyTarget({ id: comment.id, nickname: comment.authorNickname });
inputRef.current?.focus();
}}
onLike={() => likeComment(comment)}
/>
{repliesFor(comment.id).map((reply) => (
<View key={reply.id} style={{ marginLeft: 44 }}>
<CommentRow comment={reply} isReply onLike={() => likeComment(reply)} />
</View>
))}
</View>
)}
/>
)}
{/* Emoji-Bar */}
<View
style={{
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 16,
paddingVertical: 8,
borderTopWidth: 1,
borderTopColor: '#f5f5f5',
}}
>
{EMOJIS.map((e) => (
<Pressable key={e} onPress={() => setText((t) => t + e)}>
<Text style={{ fontSize: 22 }}>{e}</Text>
</Pressable>
))}
</View>
{/* Reply-Context */}
{replyTarget && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: '#fafafa',
}}
>
<Text style={{ fontSize: 12, color: '#737373', fontFamily: 'Nunito_400Regular' }}>
{t('community.reply_to')}{' '}
<Text style={{ fontFamily: 'Nunito_700Bold' }}>@{replyTarget.nickname}</Text>
</Text>
<Pressable onPress={() => setReplyTarget(null)}>
<Ionicons name="close-circle" size={16} color="#a3a3a3" />
</Pressable>
</View>
)}
{/* Input + Send-Button */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 10,
// Bei offener Tastatur kleines Padding (kein Home-Indicator sichtbar),
// sonst Safe-Area
paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom),
borderTopWidth: 1,
borderTopColor: '#e5e5e5',
}}
>
<TextInput
ref={inputRef}
value={text}
onChangeText={setText}
placeholder={t('community.comment_placeholder')}
placeholderTextColor="#a3a3a3"
style={{
flex: 1,
backgroundColor: '#fafafa',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 999,
paddingHorizontal: 16,
paddingVertical: 10,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
marginRight: 8,
}}
returnKeyType="send"
onSubmitEditing={submit}
blurOnSubmit={false}
/>
<Pressable
onPress={submit}
disabled={!text.trim() || submitting}
style={({ pressed }) => ({
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" />
)}
</Pressable>
</View>
</Animated.View>
</Animated.View>
</Modal>
);
}
type CommentRowProps = {
comment: CommunityComment;
isReply?: boolean;
onReply?: () => void;
onLike: () => void;
};
function CommentRow({ comment, isReply = false, onReply, onLike }: CommentRowProps) {
const { t } = useTranslation();
const heartScale = useRef(new Animated.Value(1)).current;
const handleLikeWithPop = useCallback(() => {
heartScale.setValue(1);
Animated.sequence([
Animated.timing(heartScale, { toValue: 1.4, duration: 120, useNativeDriver: true }),
Animated.spring(heartScale, { toValue: 1, friction: 4, tension: 80, useNativeDriver: true }),
]).start();
onLike();
}, [heartScale, onLike]);
return (
<View style={{ flexDirection: 'row', gap: 12, paddingVertical: 8 }}>
<View
style={{
width: isReply ? 24 : 32,
height: isReply ? 24 : 32,
borderRadius: isReply ? 12 : 16,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
}}
>
<Text style={{ fontSize: isReply ? 9 : 11, fontFamily: 'Nunito_700Bold', color: '#737373' }}>
{(comment.authorNickname ?? 'AN').slice(0, 2).toUpperCase()}
</Text>
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{comment.authorNickname ?? t('community.anonymous_label')}
</Text>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#404040',
lineHeight: 20,
marginTop: 2,
}}
>
{comment.content}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 6 }}>
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{formatRelativeTime(comment.createdAt)}
</Text>
{!isReply && onReply && (
<Pressable onPress={onReply}>
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_600SemiBold' }}>
{t('community.reply')}
</Text>
</Pressable>
)}
</View>
</View>
<Pressable onPress={handleLikeWithPop} style={{ alignItems: 'center', gap: 2, paddingTop: 2 }}>
<Animated.View style={{ transform: [{ scale: heartScale }] }}>
<Ionicons
name={comment.userLike ? 'heart' : 'heart-outline'}
size={16}
color={comment.userLike ? '#dc2626' : '#a3a3a3'}
/>
</Animated.View>
{comment.likesCount > 0 && (
<Text style={{ fontSize: 9, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{comment.likesCount}
</Text>
)}
</Pressable>
</View>
);
}

View File

@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { View, Text, Platform } from 'react-native';
import { Asset } from 'expo-asset';
import Rive, { Fit, Alignment } from 'rive-react-native';
// Android: Rive akzeptiert NUR raw-resource oder url, kein file:// uri.
// Asset liegt als android/app/src/main/res/raw/lyra_avatar.riv (gebundelt
// via plugins/with-rive-asset-android.js). resourceName = filename ohne
// extension, lowercase + underscores (Android raw-resource convention).
const ANDROID_RIVE_RESOURCE = 'lyra_avatar';
// Modul-Level: nur EINMAL die Asset registrieren + URI cachen.
// In Production-Builds wird die .riv ins App-Bundle gebakt (Asset.localUri
// zeigt sofort auf das Bundle-File). In Dev-Builds wird sie beim ersten Mal
// von Metro gezogen + ins App-Sandbox-Cache geschrieben — danach offline.
const RIVE_MODULE = require('../assets/lyra-avatar.riv');
let cachedRiveUri: string | null = null;
let preloadPromise: Promise<string | null> | null = null;
function preloadRiveAsset(): Promise<string | null> {
if (cachedRiveUri) return Promise.resolve(cachedRiveUri);
if (preloadPromise) return preloadPromise;
preloadPromise = Asset.fromModule(RIVE_MODULE)
.downloadAsync()
.then((asset) => {
const uri = asset.localUri ?? asset.uri;
cachedRiveUri = uri;
return uri;
})
.catch((err) => {
console.warn('[RiveAvatar] preload failed:', err?.message ?? err);
preloadPromise = null;
return null;
});
return preloadPromise;
}
// Kicke den Preload sofort beim Modul-Import an — damit der erste
// Render bereits die cached URI nutzt (außer im allerersten App-Start).
preloadRiveAsset();
export type Emotion = 'idle' | 'happy' | 'thinking' | 'empathy';
// Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue).
// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop.
const EMOTION_ANIMATIONS: Record<Emotion, string> = {
idle: 'Idle Loop',
happy: 'idle to Pose 1',
thinking: 'WALK',
empathy: '01 Wave 1',
};
const EMOTION_LABELS: Record<Emotion, string> = {
idle: 'bereit',
happy: 'froh für dich',
thinking: 'überlegt ...',
empathy: 'versteht dich',
};
const SIZE_PX: Record<'sm' | 'md' | 'lg', number> = {
sm: 40,
md: 112,
lg: 160,
};
type Props = {
emotion: Emotion;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
};
export function RiveAvatar({ emotion, size = 'md', showLabel = false }: Props) {
const px = SIZE_PX[size];
// Aktuelle Animation als deklarativer State (kein imperatives ref.play()).
const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS.idle);
// Lokale URI für die .riv-Datei — geht über expo-asset damit der File
// im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen.
// Nach erstem Load funktioniert's auch komplett offline.
const [riveUri, setRiveUri] = useState<string | null>(cachedRiveUri);
useEffect(() => {
if (riveUri) return; // schon gecached
let active = true;
preloadRiveAsset().then((uri) => {
if (active && uri) setRiveUri(uri);
});
return () => {
active = false;
};
}, [riveUri]);
useEffect(() => {
if (emotion === 'happy') {
// 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar)
setCurrentAnim('idle to Pose 1');
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
return () => clearTimeout(t);
}
setCurrentAnim(EMOTION_ANIMATIONS[emotion]);
}, [emotion]);
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View
style={{
width: px,
height: px,
// Floating-Shadow nur für md/lg — bei sm zu klein, würde unsauber wirken
...(size !== 'sm' && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 16,
elevation: 10,
}),
}}
>
<View
style={{
width: px,
height: px,
borderRadius: px / 2,
overflow: 'hidden',
backgroundColor: '#ffffff',
borderWidth: size !== 'sm' ? 4 : 0,
borderColor: '#ffffff',
}}
>
{Platform.OS === 'android' ? (
// Android: Bundle-Resource direkt (kein expo-asset Preload nötig)
<Rive
key={currentAnim}
resourceName={ANDROID_RIVE_RESOURCE}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : riveUri ? (
// iOS: file:// URI aus expo-asset Cache funktioniert
<Rive
key={currentAnim}
source={{ uri: riveUri }}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : (
<View style={{ width: px, height: px, backgroundColor: '#ffffff' }} />
)}
</View>
</View>
{showLabel && (
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
{EMOTION_LABELS[emotion]}
</Text>
)}
</View>
);
}

View File

@ -0,0 +1,31 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { colors } from '../lib/theme';
type Props = {
days: number;
size?: 'sm' | 'md' | 'lg';
};
const sizeMap = {
sm: { number: 'text-2xl', label: 'text-xs', icon: 16, padding: 'px-3 py-2' },
md: { number: 'text-5xl', label: 'text-sm', icon: 20, padding: 'px-5 py-4' },
lg: { number: 'text-7xl', label: 'text-base', icon: 24, padding: 'px-6 py-5' },
};
export function StreakBadge({ days, size = 'md' }: Props) {
const { t } = useTranslation();
const s = sizeMap[size];
return (
<View className={`items-center bg-white border border-neutral-200 rounded-3xl ${s.padding}`}>
<View className="flex-row items-center gap-2 mb-1">
<Ionicons name="flame" size={s.icon} color={colors.brandOrange} />
<Text className={`${s.number} text-neutral-900 tabular-nums`} style={{ fontFamily: 'Nunito_800ExtraBold' }}>{days}</Text>
</View>
<Text className={`${s.label} text-neutral-500 tracking-wide uppercase`} style={{ fontFamily: 'Nunito_600SemiBold' }}>
{days === 1 ? t('streak.label_one') : t('streak.label_other')} {t('streak.label_suffix')}
</Text>
</View>
);
}

View File

@ -0,0 +1,173 @@
import { useEffect, useRef } from 'react';
import { Modal, View, Text, Pressable, Animated, Easing } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
type Props = {
visible: boolean;
title: string;
message?: string;
onClose: () => void;
};
/**
* iOS-style success alert mit animiertem Check-Icon.
* - Card scaled mit Spring-overshoot rein
* - Check-Icon sequenced danach mit eigenem Spring + rotation-pop
* - Tap auf Backdrop schließt
* - OK-Button schließt
*/
export function SuccessAlert({ visible, title, message, onClose }: Props) {
const { t } = useTranslation();
const cardScale = useRef(new Animated.Value(0.8)).current;
const cardOpacity = useRef(new Animated.Value(0)).current;
const checkScale = useRef(new Animated.Value(0)).current;
const checkRotate = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
cardScale.setValue(0.8);
cardOpacity.setValue(0);
checkScale.setValue(0);
checkRotate.setValue(0);
Animated.parallel([
Animated.spring(cardScale, {
toValue: 1,
useNativeDriver: true,
friction: 7,
tension: 80,
}),
Animated.timing(cardOpacity, {
toValue: 1,
duration: 220,
useNativeDriver: true,
easing: Easing.out(Easing.cubic),
}),
Animated.sequence([
Animated.delay(140),
Animated.parallel([
Animated.spring(checkScale, {
toValue: 1,
useNativeDriver: true,
friction: 5,
tension: 180,
}),
Animated.timing(checkRotate, {
toValue: 1,
duration: 380,
useNativeDriver: true,
easing: Easing.out(Easing.back(1.7)),
}),
]),
]),
]).start();
}
}, [visible, cardScale, cardOpacity, checkScale, checkRotate]);
const rotateInterpolate = checkRotate.interpolate({
inputRange: [0, 1],
outputRange: ['-30deg', '0deg'],
});
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
{/* Backdrop — Pressable damit Tap-outside schließt */}
<Pressable
onPress={onClose}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'center',
alignItems: 'center',
padding: 24,
}}
>
{/* Card Pressable mit onPress={()=>{}} damit Tap auf Card NICHT bubbelt
* zum Backdrop und das Modal schließt. */}
<Pressable onPress={() => {}} style={{ width: '85%', maxWidth: 320 }}>
<Animated.View
style={{
backgroundColor: '#fff',
borderRadius: 22,
padding: 20,
width: '100%',
transform: [{ scale: cardScale }],
opacity: cardOpacity,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.2,
shadowRadius: 24,
elevation: 16,
}}
>
{/* Animated Check-Circle */}
<Animated.View
style={{
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#16a34a',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 12,
alignSelf: 'center',
transform: [{ scale: checkScale }, { rotate: rotateInterpolate }],
shadowColor: '#16a34a',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
}}
>
<Ionicons name="checkmark" size={32} color="#fff" />
</Animated.View>
<Text
style={{
fontSize: 16,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center',
marginBottom: 6,
}}
>
{title}
</Text>
{message && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#525252',
textAlign: 'center',
lineHeight: 19,
marginBottom: 14,
}}
>
{message}
</Text>
)}
<Pressable
onPress={onClose}
style={{
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#eff6ff',
borderWidth: 1,
borderColor: '#bfdbfe',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#007AFF' }}>
{t('common.ok')}
</Text>
</Pressable>
</Animated.View>
</Pressable>
</Pressable>
</Modal>
);
}

View File

@ -0,0 +1,352 @@
import { useState, useEffect, useRef } from 'react';
import {
Modal,
View,
Text,
TextInput,
Pressable,
KeyboardAvoidingView,
Platform,
Image,
ActivityIndicator,
Animated,
Dimensions,
Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import {
isValidDomain,
normalizeDomain,
type Tier,
} from '../../hooks/useCustomDomains';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65; // wie bei PostCommentsSheet — 65% der Screen-Höhe
type Props = {
visible: boolean;
tier: Tier;
onClose: () => void;
onAdd: (domain: string) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
};
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const [input, setInput] = useState('');
const [confirmPermanent, setConfirmPermanent] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const valid = isValidDomain(input);
const normalized = normalizeDomain(input);
// Slide-up Animation für die Sheet (translateY von SHEET_HEIGHT → 0)
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
translateY.setValue(SHEET_HEIGHT);
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: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, translateY, backdropOpacity]);
function close() {
setInput('');
setConfirmPermanent(false);
setError(null);
onClose();
}
async function handleAdd() {
if (!valid || !confirmPermanent || adding) return;
setAdding(true);
setError(null);
const result = await onAdd(input);
setAdding(false);
if (result.ok) {
close();
return;
}
if (result.alreadyGlobal) {
setError(t('blocker.add_sheet_already_global', { domain: normalized }));
} else {
setError(result.error ?? t('blocker.add_sheet_add_failed'));
}
}
const warningText =
tier.plan === 'free'
? t('blocker.add_sheet_warning_free')
: t('blocker.add_sheet_warning_pro');
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={close}>
{/* Backdrop — Tap-outside schließt */}
<Animated.View
style={{
position: 'absolute',
inset: 0 as any,
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={close} />
</Animated.View>
{/* Sheet — slide-up von unten, 65% der Screen-Höhe */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: SHEET_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
transform: [{ translateY }],
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Drag-handle */}
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: '#d4d4d4' }} />
</View>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
<Pressable onPress={close} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.cancel')}
</Text>
</Pressable>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('blocker.add_sheet_title')}
</Text>
<View style={{ width: 60 }} />
</View>
<View style={{ flex: 1, padding: 20, gap: 14 }}>
{/* Input */}
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
marginBottom: 6,
}}
>
{t('blocker.add_sheet_label')}
</Text>
<TextInput
value={input}
onChangeText={(v) => {
setInput(v);
setError(null);
}}
placeholder={t('blocker.add_sheet_placeholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
autoFocus
keyboardType="url"
returnKeyType="done"
onSubmitEditing={handleAdd}
style={{
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
}}
/>
{input && !valid && (
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
marginTop: 6,
}}
>
{t('blocker.add_sheet_invalid')}
</Text>
)}
</View>
{/* Preview */}
{valid && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: '#f5f5f5',
borderRadius: 12,
}}
>
<Image
source={{
uri: `https://www.google.com/s2/favicons?domain=${normalized}&sz=64`,
}}
style={{ width: 24, height: 24, borderRadius: 4 }}
/>
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
}}
numberOfLines={1}
>
{normalized}
</Text>
</View>
)}
{/* Warning */}
{valid && (
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#fef3c7',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fcd34d',
}}
>
<Ionicons name="lock-closed" size={18} color="#92400e" />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
lineHeight: 17,
}}
>
{warningText}
</Text>
</View>
)}
{/* Confirm-Checkbox */}
{valid && (
<Pressable
onPress={() => setConfirmPermanent((v) => !v)}
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
paddingVertical: 4,
}}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 1.5,
borderColor: confirmPermanent ? '#16a34a' : '#d4d4d4',
backgroundColor: confirmPermanent ? '#16a34a' : '#fff',
alignItems: 'center',
justifyContent: 'center',
marginTop: 1,
}}
>
{confirmPermanent && <Ionicons name="checkmark" size={16} color="#fff" />}
</View>
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
lineHeight: 18,
}}
>
{t('blocker.add_sheet_confirm_permanent')}
</Text>
</Pressable>
)}
{/* Error */}
{error && (
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}
>
{error}
</Text>
)}
<View style={{ flex: 1 }} />
{/* Add-Button */}
<Pressable
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,
})}
>
{adding ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.add_sheet_title')}
</Text>
)}
</Pressable>
</View>
</KeyboardAvoidingView>
</Animated.View>
</Modal>
);
}

View File

@ -0,0 +1,75 @@
import { View, Text, Pressable, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
remainingFormatted: string; // "23:59:42"
onCancel: () => Promise<void>;
};
export function CooldownBanner({ remainingFormatted, onCancel }: Props) {
const { t } = useTranslation();
const [cancelling, setCancelling] = useState(false);
async function handleCancel() {
setCancelling(true);
try {
await onCancel();
} finally {
setCancelling(false);
}
}
return (
<View
style={{
backgroundColor: '#fef3c7',
borderWidth: 1,
borderColor: '#fcd34d',
borderRadius: 14,
padding: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 12,
}}
>
<Ionicons name="time-outline" size={20} color="#d97706" />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#92400e' }}>
{t('blocker.cooldown_banner_title')}
</Text>
<Text
style={{
fontSize: 18,
fontFamily: 'Nunito_700Bold',
color: '#92400e',
fontVariant: ['tabular-nums'],
}}
>
{remainingFormatted}
</Text>
</View>
<Pressable
onPress={handleCancel}
disabled={cancelling}
hitSlop={8}
style={({ pressed }) => ({
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 12,
backgroundColor: '#16a34a',
opacity: pressed || cancelling ? 0.7 : 1,
})}
>
{cancelling ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: '#fff' }}>
{t('common.cancel')}
</Text>
)}
</Pressable>
</View>
);
}

View File

@ -0,0 +1,227 @@
import { Modal, View, Text, Pressable, ScrollView, ActionSheetIOS, Platform, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
visible: boolean;
onClose: () => void;
/** User triggert "Atmen" → Deflector zur Urge-Page (Click-2 ende, kein Cooldown). */
onBreathe: () => void;
/** Click-3 Bestätigung — final destruktive Aktion via ActionSheet. */
onStartCooldown: (reason: string) => Promise<void>;
};
/**
* Click 2 of 3 (Cooldown-Friction).
*
* Erklärt was Cooldown bedeutet, bietet [Atmen] als primary Deflector an,
* und ein kleines [Cooldown trotzdem starten] das einen nativen ActionSheet
* zur finalen Bestätigung öffnet (Click 3).
*/
export function DeactivationExplainerSheet({
visible,
onClose,
onBreathe,
onStartCooldown,
}: Props) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
function showFinalConfirm() {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
title: t('blocker.deactivation_actionsheet_title'),
message: t('blocker.deactivation_actionsheet_message'),
options: [t('common.cancel'), t('blocker.deactivation_start_cta')],
destructiveButtonIndex: 1,
cancelButtonIndex: 0,
},
async (idx) => {
if (idx === 1) await runCooldown();
},
);
} else {
// Android Fallback
Alert.alert(
t('blocker.deactivation_actionsheet_title'),
t('blocker.deactivation_actionsheet_message'),
[
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('blocker.deactivation_start_cta'), style: 'destructive', onPress: runCooldown },
],
);
}
}
async function runCooldown() {
setSubmitting(true);
try {
await onStartCooldown('user_requested_deactivation');
onClose();
} catch (e: any) {
Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_failed_msg'));
} finally {
setSubmitting(false);
}
}
return (
<Modal
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<View style={{ flex: 1, backgroundColor: '#fff' }}>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
<Pressable onPress={onClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.back')}
</Text>
</Pressable>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('blocker.deactivation_heading')}
</Text>
<View style={{ width: 50 }} />
</View>
<ScrollView contentContainerStyle={{ padding: 20, gap: 18 }}>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: '#0a0a0a' }}>
{t('blocker.deactivation_title')}
</Text>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#404040',
lineHeight: 22,
}}
>
{t('blocker.deactivation_intro')}
</Text>
{/* Was passiert */}
<View style={{ gap: 12 }}>
<BulletRow
icon="time-outline"
title={t('blocker.deactivation_bullet1_title')}
text={t('blocker.deactivation_bullet1_text')}
/>
<BulletRow
icon="refresh-outline"
title={t('blocker.deactivation_bullet2_title')}
text={t('blocker.deactivation_bullet2_text')}
/>
<BulletRow
icon="leaf-outline"
title={t('blocker.deactivation_bullet3_title')}
text={t('blocker.deactivation_bullet3_text')}
/>
</View>
<View style={{ height: 12 }} />
{/* Primary Deflector */}
<Pressable
onPress={onBreathe}
style={({ pressed }) => ({
backgroundColor: '#16a34a',
borderRadius: 14,
paddingVertical: 16,
paddingHorizontal: 16,
flexDirection: 'row',
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>
</Pressable>
{/* Destructive secondary */}
<Pressable
onPress={showFinalConfirm}
disabled={submitting}
hitSlop={8}
style={({ pressed }) => ({
alignSelf: 'center',
paddingVertical: 12,
opacity: pressed || submitting ? 0.5 : 1,
})}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
}}
>
{submitting ? t('blocker.deactivation_starting') : t('blocker.deactivation_start_anyway')}
</Text>
</Pressable>
</ScrollView>
</View>
</Modal>
);
}
function BulletRow({
icon,
title,
text,
}: {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
text: string;
}) {
return (
<View style={{ flexDirection: 'row', gap: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color="#525252" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{title}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
marginTop: 2,
lineHeight: 17,
}}
>
{text}
</Text>
</View>
</View>
);
}

View File

@ -0,0 +1,515 @@
import { useState, useMemo } from 'react';
import {
View,
Text,
Pressable,
Image,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { SuccessAlert } from '../SuccessAlert';
import { ConfirmAlert } from '../ConfirmAlert';
import type { CustomDomain, Tier } from '../../hooks/useCustomDomains';
// ─── Helpers ─────────────────────────────────────────────────────────────
function timeAgo(input?: string | Date): string {
if (!input) return '';
const date = typeof input === 'string' ? new Date(input) : input;
const diffMs = Date.now() - date.getTime();
const minutes = Math.floor(diffMs / 60_000);
if (minutes < 1) return 'jetzt';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w`;
const months = Math.floor(days / 30);
return `${months}mo`;
}
function timeSinceSubmit(input?: string | Date): string {
if (!input) return '';
const date = typeof input === 'string' ? new Date(input) : input;
const diffMs = Date.now() - date.getTime();
const hours = Math.floor(diffMs / 3_600_000);
if (hours < 1) {
const minutes = Math.max(1, Math.floor(diffMs / 60_000));
return `${minutes} min`;
}
if (hours < 24) return `${hours} Std`;
const days = Math.floor(hours / 24);
return `${days} Tag${days === 1 ? '' : 'e'}`;
}
type Props = {
domains: CustomDomain[];
tier: Tier;
onAdd?: () => void;
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
onRemove?: (id: string) => Promise<{ ok: boolean }>;
onUpgradePro?: () => void;
};
// Sort-Reihenfolge: User sieht zuerst was Aufmerksamkeit braucht.
// submitted (in Prüfung) > rejected (kann erneut) > active (settled OK)
const STATUS_PRIORITY: Record<string, number> = {
submitted: 0,
rejected: 1,
active: 2,
approved: 99,
};
export function DomainGrid({ domains, tier, onAdd, onSubmit, onUpgradePro }: Props) {
const { t } = useTranslation();
// Slot-relevante Domains (alles außer approved). Sortiert nach Status-Priority,
// innerhalb gleicher Priority dann newest-first by addedAt.
const visible = useMemo(() => {
return domains
.filter((d) => d.status !== 'approved')
.slice()
.sort((a, b) => {
const pa = STATUS_PRIORITY[a.status] ?? 99;
const pb = STATUS_PRIORITY[b.status] ?? 99;
if (pa !== pb) return pa - pb;
const ta = a.addedAt ? new Date(a.addedAt).getTime() : 0;
const tb = b.addedAt ? new Date(b.addedAt).getTime() : 0;
return tb - ta;
});
}, [domains]);
return (
<View style={{ gap: 12 }}>
{/* Header: Section-Title + Slot-Counter + Add-Button (inline, neben SlotPill) */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('blocker.domain_section_title')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<SlotPill tier={tier} />
{onAdd && (
<Pressable
onPress={tier.atLimit ? undefined : onAdd}
accessibilityLabel={t('blocker.domain_add_a11y')}
accessibilityState={{ disabled: tier.atLimit }}
disabled={tier.atLimit}
hitSlop={8}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 16,
// atLimit → grau + 50% opacity (deutlich visuell disabled)
backgroundColor: tier.atLimit ? '#a3a3a3' : '#007AFF',
opacity: tier.atLimit ? 0.5 : 1,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="add" size={22} color="#fff" />
</View>
</Pressable>
)}
</View>
</View>
{/* Progress-Bar — 3-stufige Color-Schwelle: <60% grün, 60-90% orange, >=90% rot */}
{(() => {
const pct = (tier.usedSlots / tier.domainLimit) * 100;
const barColor = pct >= 90 ? '#dc2626' : pct >= 60 ? '#f59e0b' : '#16a34a';
return (
<View style={{ height: 4, borderRadius: 2, backgroundColor: '#f0f0f0', overflow: 'hidden' }}>
<View
style={{
height: '100%',
width: `${Math.min(100, pct)}%`,
backgroundColor: barColor,
}}
/>
</View>
);
})()}
{/* Limit-Reached Upsell (nur Free) */}
{tier.atLimit && tier.plan === 'free' && (
<Pressable
onPress={onUpgradePro}
style={({ pressed }) => ({
backgroundColor: '#eff6ff',
borderWidth: 1,
borderColor: '#bfdbfe',
borderRadius: 12,
padding: 12,
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' }}>
{t('blocker.domain_limit_title')}
</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#3b82f6' }}>
{t('blocker.domain_limit_desc')}
</Text>
</View>
</Pressable>
)}
{/* Empty State */}
{visible.length === 0 ? (
<View
style={{
paddingVertical: 32,
paddingHorizontal: 16,
borderRadius: 14,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: '#d4d4d4',
alignItems: 'center',
}}
>
<Ionicons name="globe-outline" size={28} color="#a3a3a3" />
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
marginTop: 8,
textAlign: 'center',
}}
>
{t('blocker.domain_empty')}
</Text>
</View>
) : (
<DomainTilesGrid
domains={visible}
tier={tier}
onSubmit={onSubmit}
/>
)}
</View>
);
}
// ─── SlotPill ─────────────────────────────────────────────────────────────
function SlotPill({ tier }: { tier: Tier }) {
const bg = tier.atLimit ? '#fee2e2' : '#f5f5f5';
const fg = tier.atLimit ? '#dc2626' : '#525252';
return (
<View
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: bg,
}}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: fg }}>
{tier.usedSlots}/{tier.domainLimit}
</Text>
</View>
);
}
// ─── Tiles ────────────────────────────────────────────────────────────────
function DomainTilesGrid({
domains,
tier,
onSubmit,
}: {
domains: CustomDomain[];
tier: Tier;
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
}) {
// 3-Spalten-Grid via flex-wrap. Parent ScrollView (in blocker.tsx) handles scroll —
// KEIN nested ScrollView hier, sonst collabiert der Layout-Pass weil ScrollView
// inner-content-view keine definierte Width für %-basierte Tile-Widths hat.
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: 14 }}>
{domains.map((d) => (
<View key={d.id} style={{ width: '33.333%', paddingHorizontal: 4 }}>
<DomainTile domain={d} tier={tier} onSubmit={onSubmit} />
</View>
))}
</View>
);
}
function DomainTile({
domain,
tier,
onSubmit,
}: {
domain: CustomDomain;
tier: Tier;
onSubmit?: (id: string) => Promise<{ ok: boolean }>;
}) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const [imgError, setImgError] = useState(false);
const [successVisible, setSuccessVisible] = useState(false);
const [successContent, setSuccessContent] = useState<{ title: string; message: string }>({
title: '',
message: '',
});
const [confirmVisible, setConfirmVisible] = useState(false);
const stripped = domain.domain.replace(/^www\./, '');
const isLegend = tier.plan === 'legend';
// statusColor wird auf Badge + Button angewendet.
// iOS-native: blue (active), orange (submitted), red (rejected).
const statusColor = (() => {
switch (domain.status) {
case 'submitted':
return '#f59e0b'; // orange (Voting/Prüfung)
case 'rejected':
return '#FF3B30'; // iOS-red
default:
return '#007AFF'; // iOS-blue (active, "freigeben"-CTA)
}
})();
// Time-Color: nur Status die Aufmerksamkeit brauchen (submitted/rejected) sind farbig.
// Active = neutral gray (settled state, kein Alarm-Indikator nötig).
const timeColor = domain.status === 'active' ? '#a3a3a3' : statusColor;
const badgeLabel = (() => {
switch (domain.status) {
case 'submitted':
return isLegend ? t('blocker.domain_badge_pruefung') : t('blocker.domain_badge_voting');
case 'rejected':
return t('blocker.domain_badge_rejected');
default:
return t('blocker.domain_badge_active');
}
})();
// Tier-aware Confirm-Dialog vor Freigabe — Pro geht zu Community-Voting,
// Legend direkt zum ReBreak-Team. Animiertes Modal statt nativem Alert.
const isResubmit = domain.status === 'rejected';
const confirmTitle = isLegend
? isResubmit
? t('blocker.domain_confirm_legend_resubmit')
: t('blocker.domain_confirm_legend_first')
: isResubmit
? t('blocker.domain_confirm_community_resubmit')
: t('blocker.domain_confirm_community_first');
const confirmMessage = isLegend
? t('blocker.domain_confirm_legend_message', { domain: stripped })
: t('blocker.domain_confirm_community_message', { domain: stripped });
function openConfirm() {
if (!onSubmit) return;
setConfirmVisible(true);
}
async function handleConfirm() {
setConfirmVisible(false);
if (!onSubmit) return;
setSubmitting(true);
try {
const result = await onSubmit(domain.id);
if (result.ok) {
setSuccessContent({
title: isLegend ? t('blocker.domain_success_legend_title') : t('blocker.domain_success_community_title'),
message: isLegend
? t('blocker.domain_success_legend_message')
: t('blocker.domain_success_community_message'),
});
setSuccessVisible(true);
}
} finally {
setSubmitting(false);
}
}
const isFreeAndUsed = tier.plan === 'free' && domain.status !== 'active';
const showSubmit = tier.canSubmit && domain.status === 'active';
const showResubmit = tier.canSubmit && domain.status === 'rejected';
const showInPruefungBtn = domain.status === 'submitted';
return (
<View
style={{
backgroundColor: '#fff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 8,
// KEIN aspectRatio:1 mehr — der hat den Button auf 0 Höhe gepresst.
// minHeight statt fixer aspect-ratio: Tile darf wachsen wenn Button da ist,
// bleibt aber konsistent groß für visuelle Stabilität.
minHeight: 130,
opacity: isFreeAndUsed ? 0.55 : 1,
gap: 6,
}}
>
{/* Top-Row: Zeit links · Badge rechts — beide in Status-Color (matcht Bottom-Button). */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 4,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2 }}>
<Ionicons name="time-outline" size={9} color={timeColor} />
<Text style={{ fontSize: 9, fontFamily: 'Nunito_600SemiBold', color: timeColor }}>
{timeAgo(domain.addedAt)}
</Text>
</View>
<View
style={{
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 999,
backgroundColor: statusColor,
}}
>
<Text style={{ fontSize: 8, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{badgeLabel}
</Text>
</View>
</View>
{/* Mitte: Favicon + Domain-Name (zentriert, flex-1) */}
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 8 }}>
{!imgError ? (
<Image
source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=128` }}
style={{ width: 26, height: 26, borderRadius: 5 }}
onError={() => setImgError(true)}
/>
) : (
<View
style={{
width: 26,
height: 26,
borderRadius: 5,
backgroundColor: '#525252',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 9, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{stripped.slice(0, 2).toUpperCase()}
</Text>
</View>
)}
<Text
numberOfLines={1}
style={{
fontSize: 10,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
textAlign: 'center',
width: '100%',
}}
>
{stripped}
</Text>
</View>
{/* Bottom-Slot: ALWAYS rendered Container (32px), Inhalt je nach Status.
* Garantiert konsistente Tile-Höhe + sichtbaren Button. */}
<View style={{ height: 28 }}>
{showInPruefungBtn && (
<View
style={{
flex: 1,
backgroundColor: '#f59e0b',
borderRadius: 6,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: '#fff' }}>
{isLegend ? t('blocker.domain_btn_rebreak_prueft') : t('blocker.domain_btn_in_abstimmung')}
</Text>
</View>
)}
{showSubmit && (
<Pressable
onPress={openConfirm}
disabled={submitting}
style={{
flex: 1,
borderRadius: 6,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#007AFF',
alignItems: 'center',
justifyContent: 'center',
opacity: submitting ? 0.5 : 1,
}}
>
{submitting ? (
<ActivityIndicator size="small" color="#007AFF" />
) : (
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: '#007AFF' }}>
{t('blocker.domain_btn_freigeben')}
</Text>
)}
</Pressable>
)}
{showResubmit && (
<Pressable
onPress={openConfirm}
disabled={submitting}
style={{
flex: 1,
borderRadius: 6,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#FF3B30',
alignItems: 'center',
justifyContent: 'center',
opacity: submitting ? 0.5 : 1,
}}
>
{submitting ? (
<ActivityIndicator size="small" color="#FF3B30" />
) : (
<Text style={{ fontSize: 10, fontFamily: 'Nunito_600SemiBold', color: '#FF3B30' }}>
{t('blocker.domain_btn_erneut')}
</Text>
)}
</Pressable>
)}
</View>
{/* Confirm-Modal vor Submit (statt nativer Alert.alert — selber animation-Style wie SuccessAlert) */}
<ConfirmAlert
visible={confirmVisible}
title={confirmTitle}
message={confirmMessage}
confirmLabel={t('blocker.domain_btn_freigeben')}
icon={isLegend ? 'shield-checkmark' : 'people'}
iconColor="#f59e0b"
onConfirm={handleConfirm}
onCancel={() => setConfirmVisible(false)}
/>
{/* Success-Alert mit animiertem Check-Icon nach erfolgreichem Submit */}
<SuccessAlert
visible={successVisible}
title={successContent.title}
message={successContent.message}
onClose={() => setSuccessVisible(false)}
/>
</View>
);
}

View File

@ -0,0 +1,111 @@
import { useState } from 'react';
import { View, Text, Switch, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
type Props = {
icon: React.ComponentProps<typeof Ionicons>['name'];
title: string;
subtitle: string;
/** Wenn true: zeigt grünen Check statt Switch (Layer ist an). */
active: boolean;
/** Aktivierung (zeigt System-Dialog). UI hat nur read-on-flow,
* Toggle-off ist nicht hier passiert nur über Cooldown. */
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
/** Optional: Hinweistext unter Subtitle für commit-heavy Layer. */
warning?: string;
};
export function LayerSwitchCard({ icon, title, subtitle, active, onActivate, warning }: Props) {
const [busy, setBusy] = useState(false);
async function handleSwitch(v: boolean) {
if (!v || active || busy) return;
setBusy(true);
try {
await onActivate();
} finally {
setBusy(false);
}
}
const iconBg = active ? '#dcfce7' : '#f5f5f5';
const iconColor = active ? '#16a34a' : '#737373';
const borderColor = active ? '#86efac' : '#e5e5e5';
const cardBg = active ? '#f0fdf4' : '#ffffff';
return (
<View
style={{
backgroundColor: cardBg,
borderWidth: 1,
borderColor,
borderRadius: 16,
padding: 14,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View
style={{
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: iconBg,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={20} color={iconColor} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{title}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
marginTop: 2,
}}
>
{subtitle}
</Text>
</View>
{busy ? (
<ActivityIndicator color={iconColor} />
) : active ? (
<Ionicons name="checkmark-circle" size={28} color="#16a34a" />
) : (
<Switch value={false} onValueChange={handleSwitch} trackColor={{ true: '#16a34a' }} />
)}
</View>
{warning && !active && (
<View
style={{
marginTop: 10,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
flexDirection: 'row',
gap: 8,
}}
>
<Ionicons name="information-circle" size={16} color="#525252" />
<Text
style={{
flex: 1,
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#525252',
lineHeight: 16,
}}
>
{warning}
</Text>
</View>
)}
</View>
);
}

View File

@ -0,0 +1,167 @@
import { View, Text, Switch, Pressable, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import type { ProtectionState } from '../../lib/protection';
import { colors } from '../../lib/theme';
type Props = {
state: ProtectionState;
loading: boolean;
/** Aktiviert den Schutz. UI sollte missingLayers den User durchsteppen lassen. */
onActivate: () => Promise<void>;
/** Click 1 of 3 — öffnet ProtectionDetailsSheet. KEIN direktes deactivate! */
onPressSettings: () => void;
};
export function ProtectionCard({ state, loading, onActivate, onPressSettings }: Props) {
const { t } = useTranslation();
const isActive = state.phase === 'active' || state.phase === 'cooldownActive';
const isCooldown = state.phase === 'cooldownActive';
const subtitle = (() => {
if (state.phase === 'inactive') return t('blocker.protection_subtitle_inactive');
if (state.phase === 'cooldownActive') return t('blocker.protection_subtitle_cooldown');
if (state.plan === 'free') {
return t('blocker.protection_subtitle_free', { count: state.blocklistCount });
}
if (state.plan === 'legend') {
return t('blocker.protection_subtitle_legend');
}
return t('blocker.protection_subtitle_pro');
})();
const cardBg = isCooldown ? '#fef3c7' : isActive ? '#dcfce7' : '#ffffff';
const cardBorder = isCooldown ? '#fcd34d' : isActive ? '#86efac' : '#e5e5e5';
const iconBg = isCooldown ? '#fde68a' : isActive ? '#bbf7d0' : '#f5f5f5';
const iconColor = isCooldown ? '#d97706' : isActive ? '#16a34a' : '#a3a3a3';
return (
<View
style={{
backgroundColor: cardBg,
borderWidth: 1,
borderColor: cardBorder,
borderRadius: 18,
padding: 14,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View
style={{
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: iconBg,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons
name={isActive ? 'shield-checkmark' : 'shield-outline'}
size={22}
color={iconColor}
/>
</View>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
}}
>
{t('blocker.protection_card_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
marginTop: 2,
}}
>
{subtitle}
</Text>
</View>
{/* Loading: Spinner. Inactive: Switch zum aktivieren. Active: Settings-Icon */}
{loading ? (
<ActivityIndicator color={iconColor} />
) : isActive ? (
<Pressable
onPress={onPressSettings}
hitSlop={10}
style={({ pressed }) => ({
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" />
</Pressable>
) : (
<Switch
value={false}
onValueChange={(v) => {
if (v) onActivate();
}}
trackColor={{ true: '#16a34a' }}
/>
)}
</View>
{/* Stats-Row nur wenn aktiv und kein Cooldown */}
{state.phase === 'active' && (
<View
style={{
flexDirection: 'row',
gap: 10,
marginTop: 14,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
}}
>
<Stat label={t('blocker.protection_stat_domains')} value={formatCount(state.blocklistCount)} />
<Stat label={t('blocker.protection_stat_method')} value={t('blocker.protection_stat_method_dns')} />
<Stat label={t('blocker.protection_stat_status')} value={t('blocker.protection_stat_status_live')} valueColor={colors.success} />
</View>
)}
</View>
);
}
function Stat({
label,
value,
valueColor = '#0a0a0a',
}: {
label: string;
value: string;
valueColor?: string;
}) {
return (
<View style={{ flex: 1, alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor }}>
{value}
</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>
{label}
</Text>
</View>
);
}
function formatCount(n: number): string {
if (n >= 1000) return `${Math.floor(n / 1000)}k+`;
return String(n);
}

View File

@ -0,0 +1,703 @@
import { useEffect, useRef, useState } from 'react';
import {
Modal,
View,
Text,
Pressable,
ScrollView,
Dimensions,
Animated,
PanResponder,
ActivityIndicator,
Easing,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Svg, { Path, Circle } from 'react-native-svg';
import type { ProtectionState } from '../../lib/protection';
import { apiFetch } from '../../lib/api';
type Props = {
visible: boolean;
state: ProtectionState;
onClose: () => void;
onRequestDeactivation: () => void;
onTalkToLyra: () => void;
};
type StatsResponse = {
current: number;
weeklyAdded: number;
monthlyAdded: number;
history: { label: string; count: number }[];
submissions: { inVote: number; inReview: number };
mySubmissions: { active: number; inVote: number; inReview: number };
avgPerUser: number;
avgApprovalWaitDays: number;
};
const SCREEN_HEIGHT = Dimensions.get('window').height;
const DEFAULT_HEIGHT = SCREEN_HEIGHT * 0.85;
const EXPANDED_HEIGHT = SCREEN_HEIGHT * 0.95;
const MIN_HEIGHT = SCREEN_HEIGHT * 0.4;
const DISMISS_HEIGHT = SCREEN_HEIGHT * 0.3;
// Brand colors
const HERO_COLOR = '#f97316'; // orange-500 (counter accent)
const SEG_ACTIVE = '#16a34a';
const SEG_VOTE = '#3b82f6';
const SEG_REVIEW = '#f59e0b';
export function ProtectionDetailsSheet({
visible,
state,
onClose,
onRequestDeactivation,
}: Props) {
const { t, i18n } = useTranslation();
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
const sheetHeight = useRef(new Animated.Value(DEFAULT_HEIGHT)).current;
const dismissY = useRef(new Animated.Value(0)).current;
const currentHeight = useRef(DEFAULT_HEIGHT);
useEffect(() => {
if (visible) {
sheetHeight.setValue(DEFAULT_HEIGHT);
dismissY.setValue(0);
currentHeight.current = DEFAULT_HEIGHT;
}
}, [visible, sheetHeight, dismissY]);
const handleClose = () => {
sheetHeight.setValue(DEFAULT_HEIGHT);
dismissY.setValue(0);
currentHeight.current = DEFAULT_HEIGHT;
onClose();
};
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderTerminationRequest: () => false,
onPanResponderMove: (_, g) => {
const next = currentHeight.current - g.dy;
const clamped = Math.max(DISMISS_HEIGHT - 60, Math.min(EXPANDED_HEIGHT + 20, next));
sheetHeight.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const finalH = currentHeight.current - g.dy;
const velocity = g.vy;
if (finalH < DISMISS_HEIGHT || velocity > 1.5) {
Animated.timing(dismissY, {
toValue: SCREEN_HEIGHT,
duration: 200,
useNativeDriver: true,
}).start(() => handleClose());
return;
}
let target = finalH;
if (velocity < -1.5) target = EXPANDED_HEIGHT;
const clamped = Math.max(MIN_HEIGHT, Math.min(EXPANDED_HEIGHT, target));
Animated.spring(sheetHeight, {
toValue: clamped,
useNativeDriver: false,
friction: 9,
tension: 70,
}).start();
currentHeight.current = clamped;
},
}),
).current;
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
useEffect(() => {
if (!visible) return;
let alive = true;
setLoadingStats(true);
apiFetch<StatsResponse>('/api/blocklist/stats')
.then((res) => { if (alive) setStats(res); })
.catch(() => { /* silent */ })
.finally(() => { if (alive) setLoadingStats(false); });
return () => { alive = false; };
}, [visible]);
const globalCount = stats?.current ?? state.blocklistCount;
const weeklyAdded = stats?.weeklyAdded ?? 0;
const monthlyAdded = stats?.monthlyAdded ?? 0;
const myActive = stats?.mySubmissions?.active ?? 0;
const myInVote = stats?.mySubmissions?.inVote ?? 0;
const myInReview = stats?.mySubmissions?.inReview ?? 0;
const avgPerUser = stats?.avgPerUser ?? 0;
const avgWait = stats?.avgApprovalWaitDays ?? 0;
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={handleClose}
statusBarTranslucent
>
<Pressable
onPress={handleClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' }}
/>
<Animated.View
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: sheetHeight,
}}
>
<Animated.View
style={{
flex: 1,
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: 'hidden',
transform: [{ translateY: dismissY }],
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.08,
shadowRadius: 8,
}}
>
{/* Drag-Bar */}
<View
{...panResponder.panHandlers}
style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}
>
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: '#d4d4d8' }} />
</View>
{/* Header */}
<View
{...panResponder.panHandlers}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 4,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
<View style={{ width: 50 }} />
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('blocker.details_title')}
</Text>
<Pressable onPress={handleClose} hitSlop={10} style={{ width: 50, alignItems: 'flex-end' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('blocker.details_done')}
</Text>
</Pressable>
</View>
<ScrollView
contentContainerStyle={{ padding: 20, paddingBottom: 40, gap: 18 }}
showsVerticalScrollIndicator
>
{loadingStats && !stats ? (
<View style={{ padding: 40, alignItems: 'center' }}>
<ActivityIndicator color="#737373" />
</View>
) : null}
{/* HERO Globale geblockte Domains: Counter (slow, color) + 2 Delta-Badges */}
<View
style={{
padding: 20,
borderRadius: 16,
backgroundColor: '#0a0a0a',
gap: 14,
}}
>
<View>
<Text style={{ fontSize: 11, color: '#a3a3a3', fontFamily: 'Nunito_700Bold', letterSpacing: 0.5, textTransform: 'uppercase' }}>
{t('blocker.kpi_global_label')}
</Text>
<AnimatedCounter
value={globalCount}
locale={localeTag}
durationMs={2400}
style={{ fontSize: 38, color: HERO_COLOR, fontFamily: 'Nunito_900Black', letterSpacing: -1, marginTop: 2 }}
/>
</View>
<View style={{ flexDirection: 'row', gap: 10 }}>
<DeltaBadge value={weeklyAdded} label={t('blocker.delta_week')} locale={localeTag} />
<DeltaBadge value={monthlyAdded} label={t('blocker.delta_month')} locale={localeTag} />
</View>
</View>
{/* SUBMISSIONS Half Donut mit center-number + center-legend */}
<View
style={{
padding: 18,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
backgroundColor: '#fff',
gap: 8,
}}
>
<View>
<Text style={{ fontSize: 13, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
{t('blocker.kpi_submissions_title')}
</Text>
<Text style={{ fontSize: 11, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: 2 }}>
{t('blocker.kpi_submissions_subtitle')}
</Text>
</View>
<HalfDonut
segments={[
{ value: myActive, color: SEG_ACTIVE },
{ value: myInVote, color: SEG_VOTE },
{ value: myInReview, color: SEG_REVIEW },
]}
centerValue={myActive + myInVote + myInReview}
centerLabel={t('blocker.kpi_my_submissions')}
/>
{/* Centered Legend */}
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-start',
gap: 14,
marginTop: 4,
}}
>
<LegendItem color={SEG_ACTIVE} label={t('blocker.kpi_status_active')} value={myActive} />
<LegendItem color={SEG_VOTE} label={t('blocker.kpi_status_vote')} value={myInVote} />
<LegendItem color={SEG_REVIEW} label={t('blocker.kpi_status_review')} value={myInReview} />
</View>
</View>
{/* AVG KPIs kleiner */}
<View style={{ flexDirection: 'row', gap: 10 }}>
<KpiCard
icon="person-circle-outline"
label={t('blocker.kpi_avg_per_user')}
value={avgPerUser}
locale={localeTag}
decimals={1}
/>
<KpiCard
icon="time-outline"
label={t('blocker.kpi_avg_wait')}
value={avgWait}
locale={localeTag}
decimals={1}
suffix={t('blocker.kpi_days_suffix')}
/>
</View>
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
<View style={{ gap: 8, marginTop: 4 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
}}
>
<Text
style={{
flex: 1,
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
letterSpacing: 0.5,
textTransform: 'uppercase',
}}
>
{t('blocker.faq_heading')}
</Text>
<Ionicons name="help-circle-outline" size={18} color="#737373" />
</View>
{[1, 2, 3, 4].map((n) => (
<FaqItem
key={n}
question={t(`blocker.faq${n}_q`)}
answer={t(`blocker.faq${n}_a`)}
/>
))}
</View>
{/* MEHR INFO outline button, Icon + Label nebeneinander (flex-row, NICHT col) */}
<Pressable
onPress={onRequestDeactivation}
style={({ pressed }) => ({
marginTop: 4,
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 12,
borderWidth: 1.5,
borderColor: HERO_COLOR,
backgroundColor: pressed ? '#fed7aa' : '#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>
</Pressable>
</ScrollView>
</Animated.View>
</Animated.View>
</Modal>
);
}
// ─── Animated Counter ──────────────────────────────────────────────────────
function AnimatedCounter({
value,
locale,
decimals = 0,
durationMs = 1200,
style,
}: {
value: number;
locale: string;
decimals?: number;
durationMs?: number;
style?: any;
}) {
const anim = useRef(new Animated.Value(0)).current;
const [display, setDisplay] = useState(0);
useEffect(() => {
anim.setValue(0);
const listener = anim.addListener(({ value: v }) => {
setDisplay(v * value);
});
Animated.timing(anim, {
toValue: 1,
duration: durationMs,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => anim.removeListener(listener);
}, [value, anim, durationMs]);
const formatted = display.toLocaleString(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return <Text style={style}>{formatted}</Text>;
}
// ─── Delta Badge (e.g. "+25 diese Woche ↗") ────────────────────────────────
function DeltaBadge({
value,
label,
locale,
}: {
value: number;
label: string;
locale: string;
}) {
const formatted = `+${value.toLocaleString(locale)}`;
return (
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
backgroundColor: '#1f1f1f',
borderWidth: 1,
borderColor: '#262626',
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(22,163,74,0.15)',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="arrow-up" size={14} color="#22c55e" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 13, color: '#fff', fontFamily: 'Nunito_900Black', letterSpacing: -0.3 }}>
{formatted}
</Text>
<Text style={{ fontSize: 10, color: '#a3a3a3', fontFamily: 'Nunito_400Regular' }}>
{label}
</Text>
</View>
</View>
);
}
// ─── KPI Card (small) ──────────────────────────────────────────────────────
function KpiCard({
icon,
label,
value,
locale,
decimals = 0,
suffix,
}: {
icon: any;
label: string;
value: number;
locale: string;
decimals?: number;
suffix?: string;
}) {
return (
<View
style={{
flex: 1,
padding: 12,
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e5e5',
backgroundColor: '#fafafa',
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name={icon} size={14} color="#737373" />
<Text style={{ flex: 1, fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', lineHeight: 13 }}>
{label}
</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 4 }}>
<AnimatedCounter
value={value}
locale={locale}
decimals={decimals}
style={{ fontSize: 18, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.3 }}
/>
{suffix ? (
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_700Bold' }}>{suffix}</Text>
) : null}
</View>
</View>
);
}
// ─── Legend Item (compact, centered row) ───────────────────────────────────
function LegendItem({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 5 }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: color }} />
<Text style={{ fontSize: 11, color: '#525252', fontFamily: 'Nunito_700Bold' }}>{value}</Text>
</View>
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular' }}>{label}</Text>
</View>
);
}
// ─── Half Donut (multi-segment) ────────────────────────────────────────────
function HalfDonut({
segments,
centerValue,
centerLabel,
}: {
segments: { value: number; color: string }[];
centerValue: number;
centerLabel: string;
}) {
const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0));
const W = 220;
const H = 130;
const cx = W / 2;
const cy = H - 8;
const r = 90;
const stroke = 18;
// Compute cumulative angles in [180, 360]
let cumAngle = 180;
const arcs = segments.map((seg) => {
const startAngle = cumAngle;
const endAngle = cumAngle + 180 * (seg.value / total);
cumAngle = endAngle;
return { ...seg, startAngle, endAngle };
});
const animProgress = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0);
useEffect(() => {
animProgress.setValue(0);
const l = animProgress.addListener(({ value }) => setProgress(value));
Animated.timing(animProgress, {
toValue: 1,
duration: 1100,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => animProgress.removeListener(l);
}, [centerValue, animProgress]);
return (
<View style={{ alignItems: 'center', justifyContent: 'center' }}>
<Svg width={W} height={H}>
{/* Background track */}
<Path
d={arcPath(cx, cy, r, 180, 360)}
stroke="#f0f0f0"
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
{arcs.map((a, i) => {
const animatedEnd =
a.startAngle + (a.endAngle - a.startAngle) * progress;
if (animatedEnd <= a.startAngle + 0.5) return null;
return (
<Path
key={i}
d={arcPath(cx, cy, r, a.startAngle, animatedEnd)}
stroke={a.color}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
/>
);
})}
{centerValue === 0 && (
<Circle cx={cx} cy={cy - r + stroke / 2} r={3} fill="#d4d4d8" />
)}
</Svg>
{/* Center number — exactly centered horizontally + vertically inside semicircle */}
<View
pointerEvents="none"
style={{
position: 'absolute',
left: 0,
right: 0,
top: H / 2 + 4,
alignItems: 'center',
}}
>
<Text style={{ fontSize: 30, fontFamily: 'Nunito_900Black', color: '#0a0a0a', letterSpacing: -0.5 }}>
{centerValue}
</Text>
<Text style={{ fontSize: 10, color: '#737373', fontFamily: 'Nunito_400Regular', marginTop: -2 }}>
{centerLabel}
</Text>
</View>
</View>
);
}
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
const start = polar(cx, cy, r, startDeg);
const end = polar(cx, cy, r, endDeg);
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
}
function polar(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
// ─── FAQ Item (chevron AT END of header row, on right) ─────────────────────
function FaqItem({ question, answer }: { question: string; answer: string }) {
const [open, setOpen] = useState(false);
const rotateAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(rotateAnim, {
toValue: open ? 1 : 0,
duration: 200,
useNativeDriver: true,
}).start();
}, [open, rotateAnim]);
const rotate = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '90deg'],
});
return (
<View
style={{
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 12,
overflow: 'hidden',
backgroundColor: '#fff',
}}
>
<Pressable
onPress={() => setOpen((v) => !v)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 14,
backgroundColor: pressed ? '#fafafa' : '#fff',
})}
>
<Text style={{ flex: 1, fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a', lineHeight: 18, paddingRight: 12 }}>
{question}
</Text>
<Animated.View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
transform: [{ rotate }],
}}
>
<Ionicons name="chevron-forward" size={16} color="#525252" />
</Animated.View>
</Pressable>
{open && (
<View style={{ paddingHorizontal: 14, paddingBottom: 14, paddingTop: 0 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: '#525252', lineHeight: 19 }}>
{answer}
</Text>
</View>
)}
</View>
);
}

View File

@ -0,0 +1,130 @@
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import type { ProtectionState } from '../../lib/protection';
type Props = {
state: ProtectionState;
/** Click-1 of 3-Click-Cooldown-Trigger — öffnet ProtectionDetailsSheet. */
onPressSettings: () => void;
};
/**
* Wird gezeigt sobald Family Controls aktiv ist der Schutz ist dann
* "locked in" und kann nur über den Cooldown-Flow deaktiviert werden.
* Daher: KEINE Switches mehr, nur ein Settings-Icon das den 3-Click-Flow startet.
*/
export function ProtectionLockedCard({ state, onPressSettings }: Props) {
const { t } = useTranslation();
const isCooldown = state.phase === 'cooldownActive';
const cardBg = isCooldown ? '#fef3c7' : '#dcfce7';
const cardBorder = isCooldown ? '#fcd34d' : '#86efac';
const iconBg = isCooldown ? '#fde68a' : '#bbf7d0';
const iconColor = isCooldown ? '#d97706' : '#16a34a';
const subtitle = (() => {
if (isCooldown) return t('blocker.protection_subtitle_cooldown');
if (state.plan === 'legend') {
return t('blocker.protection_subtitle_legend');
}
if (state.plan === 'pro') {
return t('blocker.protection_subtitle_pro');
}
return t('blocker.protection_subtitle_free', { count: state.blocklistCount });
})();
return (
<View
style={{
backgroundColor: cardBg,
borderWidth: 1,
borderColor: cardBorder,
borderRadius: 18,
padding: 14,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View
style={{
width: 44,
height: 44,
borderRadius: 14,
backgroundColor: iconBg,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="shield-checkmark" size={22} color={iconColor} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('blocker.protection_card_locked_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#525252',
marginTop: 2,
}}
>
{subtitle}
</Text>
</View>
<Pressable
onPress={onPressSettings}
hitSlop={10}
style={({ pressed }) => ({
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" />
</Pressable>
</View>
{/* Stats nur wenn aktiv und kein Cooldown */}
{!isCooldown && (
<View
style={{
flexDirection: 'row',
gap: 10,
marginTop: 14,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
}}
>
<Stat label={t('blocker.protection_stat_domains')} value={formatCount(state.blocklistCount)} />
<Stat label={t('blocker.protection_stat_method')} value={t('blocker.protection_stat_method_native')} />
<Stat label={t('blocker.protection_stat_status')} value={t('blocker.protection_stat_status_live')} valueColor="#16a34a" />
</View>
)}
</View>
);
}
function Stat({ label, value, valueColor = '#0a0a0a' }: { label: string; value: string; valueColor?: string }) {
return (
<View style={{ flex: 1, alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: valueColor }}>{value}</Text>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}>{label}</Text>
</View>
);
}
function formatCount(n: number): string {
if (n >= 1000) return `${Math.floor(n / 1000)}k+`;
return String(n);
}

View File

@ -0,0 +1,442 @@
import { useState, useRef } from 'react';
import {
View,
Text,
Pressable,
Image,
StyleSheet,
Modal,
Alert,
Platform,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { resolveAvatar } from '../../lib/resolveAvatar';
export type ChatMsg = {
id: string;
userId: string;
nickname?: string | null;
avatar?: string | null;
content: string;
replyTo?: {
id: string;
userId: string;
nickname?: string | null;
content: string;
attachmentType?: string | null;
} | null;
attachmentUrl?: string | null;
attachmentType?: string | null;
attachmentName?: string | null;
likesCount: number;
likedByMe?: boolean;
createdAt: string;
isOwn: boolean;
readAt?: string | null;
};
type Props = {
msg: ChatMsg;
showName?: boolean;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
hideReadStatus?: boolean;
onReply: (msg: ChatMsg) => void;
onLike: (msg: ChatMsg) => void;
onOpenImage: (url: string) => void;
};
function formatTime(ts: string) {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
export function ChatBubble({
msg,
showName = false,
isFirstInGroup = true,
isLastInGroup = true,
hideReadStatus = false,
onReply,
onLike,
onOpenImage,
}: Props) {
const { t } = useTranslation();
const [actionsOpen, setActionsOpen] = useState(false);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isImageOnly =
!!msg.attachmentUrl && msg.attachmentType === 'image' && !msg.content && !msg.replyTo;
const replyHasAttachment = msg.replyTo?.attachmentType === 'image';
const avatarUrl = msg.avatar ? msg.avatar : resolveAvatar(null, msg.nickname ?? '?');
const cornerStyle = msg.isOwn
? isLastInGroup
? { borderBottomRightRadius: 6 }
: { borderTopRightRadius: 6, borderBottomRightRadius: 6 }
: isLastInGroup
? { borderBottomLeftRadius: 6 }
: { borderTopLeftRadius: 6, borderBottomLeftRadius: 6 };
function copyContent() {
if (msg.content) Clipboard.setStringAsync(msg.content);
setActionsOpen(false);
}
return (
<>
<View
style={[
styles.row,
{ justifyContent: msg.isOwn ? 'flex-end' : 'flex-start' },
{ marginTop: isFirstInGroup ? 8 : 2 },
]}
>
{/* Avatar slot left (last of group, not own) */}
{!msg.isOwn && (
<View style={styles.avatarSlot}>
{isLastInGroup ? (
<Image source={{ uri: avatarUrl }} style={styles.avatar} />
) : null}
</View>
)}
<View style={[styles.bubbleCol, { alignItems: msg.isOwn ? 'flex-end' : 'flex-start' }]}>
{showName && !msg.isOwn && isFirstInGroup && (
<Text style={styles.nickname} numberOfLines={1}>
{msg.nickname ?? '?'}
</Text>
)}
<Pressable
delayLongPress={350}
onLongPress={() => setActionsOpen(true)}
onPress={() => {
/* tap eats - keeps long-press primary */
}}
style={[
styles.bubble,
msg.isOwn ? styles.bubbleOwn : styles.bubbleOther,
cornerStyle,
isImageOnly && { padding: 4 },
]}
>
{/* Reply preview */}
{msg.replyTo && (
<Pressable
onPress={() => {
/* could implement scroll-to */
}}
style={[
styles.replyPreview,
{
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.18)' : '#e5e5e5',
borderLeftColor: msg.isOwn ? '#fff' : '#007AFF',
},
]}
>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: msg.isOwn ? '#fff' : '#007AFF',
}}
numberOfLines={1}
>
{msg.replyTo.nickname ?? '?'}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: msg.isOwn ? 'rgba(255,255,255,0.85)' : '#737373',
marginTop: 1,
}}
numberOfLines={1}
>
{replyHasAttachment && (
<Ionicons name="image" size={11} color={msg.isOwn ? '#fff' : '#737373'} />
)}{' '}
{msg.replyTo.content || (replyHasAttachment ? t('chat.image_attachment') : '…')}
</Text>
</Pressable>
)}
{/* Image attachment */}
{msg.attachmentUrl && msg.attachmentType === 'image' && (
<Pressable
onPress={() => onOpenImage(msg.attachmentUrl!)}
style={[styles.imageWrap, msg.content ? { marginBottom: 4 } : null]}
>
<Image
source={{ uri: msg.attachmentUrl }}
style={styles.image}
resizeMode="cover"
/>
{isImageOnly && (
<View style={styles.imageTimeOverlay}>
{msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 4 }}>
<Ionicons name="heart" size={10} color="#f87171" />
<Text style={{ fontSize: 10, color: '#fff', marginLeft: 2 }}>
{msg.likesCount}
</Text>
</View>
)}
<Text style={{ fontSize: 10, color: '#fff' }}>{formatTime(msg.createdAt)}</Text>
</View>
)}
</Pressable>
)}
{/* File attachment */}
{msg.attachmentUrl && msg.attachmentType !== 'image' && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 8,
borderRadius: 10,
marginBottom: 4,
backgroundColor: msg.isOwn ? 'rgba(255,255,255,0.15)' : '#e5e5e5',
}}
>
<Ionicons
name="document-attach"
size={18}
color={msg.isOwn ? '#fff' : '#525252'}
/>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
marginLeft: 8,
color: msg.isOwn ? '#fff' : '#171717',
flex: 1,
}}
numberOfLines={1}
>
{msg.attachmentName ?? t('chat.file_attachment')}
</Text>
</View>
)}
{/* Content */}
{msg.content !== '' && (
<Text
style={[
styles.content,
{ color: msg.isOwn ? '#ffffff' : '#171717', paddingRight: 48 },
]}
>
{msg.content}
</Text>
)}
{/* Footer */}
{!isImageOnly && (
<View style={styles.footer}>
{msg.likesCount > 0 && (
<View style={{ flexDirection: 'row', alignItems: 'center', marginRight: 3 }}>
<Ionicons name="heart" size={9} color="#f87171" />
<Text
style={{
fontSize: 9,
marginLeft: 1,
color: msg.isOwn ? 'rgba(255,255,255,0.7)' : '#a3a3a3',
}}
>
{msg.likesCount}
</Text>
</View>
)}
<Text
style={{
fontSize: 9,
color: msg.isOwn ? 'rgba(255,255,255,0.65)' : '#a3a3a3',
}}
>
{formatTime(msg.createdAt)}
</Text>
{msg.isOwn && !hideReadStatus && (
<Ionicons
name={msg.readAt ? 'checkmark-done' : 'checkmark'}
size={11}
color={msg.readAt ? '#93c5fd' : 'rgba(255,255,255,0.65)'}
style={{ marginLeft: 2 }}
/>
)}
</View>
)}
</Pressable>
</View>
</View>
{/* Long-press action sheet */}
<Modal
visible={actionsOpen}
transparent
animationType="fade"
onRequestClose={() => setActionsOpen(false)}
>
<Pressable style={styles.sheetBackdrop} onPress={() => setActionsOpen(false)}>
<Pressable style={styles.sheet} onPress={() => {}}>
<View style={styles.sheetGrabber} />
<Pressable
style={styles.sheetItem}
onPress={() => {
setActionsOpen(false);
onReply(msg);
}}
>
<Ionicons name="arrow-undo" size={18} color="#007AFF" />
<Text style={styles.sheetText}>{t('chat.reply')}</Text>
</Pressable>
<Pressable
style={styles.sheetItem}
onPress={() => {
setActionsOpen(false);
onLike(msg);
}}
>
<Ionicons
name={msg.likedByMe ? 'heart' : 'heart-outline'}
size={18}
color={msg.likedByMe ? '#f87171' : '#007AFF'}
/>
<Text style={styles.sheetText}>
{msg.likedByMe ? t('chat.unlike') : t('chat.like')}
</Text>
</Pressable>
{msg.content !== '' && (
<Pressable style={styles.sheetItem} onPress={copyContent}>
<Ionicons name="copy-outline" size={18} color="#007AFF" />
<Text style={styles.sheetText}>{t('chat.copy')}</Text>
</Pressable>
)}
</Pressable>
</Pressable>
</Modal>
</>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
paddingHorizontal: 8,
},
avatarSlot: {
width: 30,
marginRight: 4,
justifyContent: 'flex-end',
},
avatar: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: '#e5e5e5',
},
bubbleCol: {
maxWidth: '78%',
},
nickname: {
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: '#007AFF',
marginBottom: 2,
marginLeft: 10,
},
bubble: {
borderRadius: 18,
paddingHorizontal: 12,
paddingVertical: 6,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 1,
shadowOffset: { width: 0, height: 1 },
},
bubbleOwn: {
backgroundColor: '#007AFF',
},
bubbleOther: {
backgroundColor: '#ffffff',
borderWidth: StyleSheet.hairlineWidth,
borderColor: '#e5e5e5',
},
replyPreview: {
borderLeftWidth: 3,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 4,
marginBottom: 4,
},
imageWrap: {
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
image: {
width: 220,
height: 220,
backgroundColor: '#f5f5f5',
},
imageTimeOverlay: {
position: 'absolute',
bottom: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.5)',
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
flexDirection: 'row',
alignItems: 'center',
},
content: {
fontSize: 14,
lineHeight: 20,
fontFamily: 'Nunito_400Regular',
},
footer: {
position: 'absolute',
bottom: 4,
right: 8,
flexDirection: 'row',
alignItems: 'center',
},
sheetBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 8,
paddingBottom: Platform.OS === 'ios' ? 32 : 16,
},
sheetGrabber: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#d4d4d4',
alignSelf: 'center',
marginBottom: 10,
},
sheetItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 12,
},
sheetText: {
fontSize: 15,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
marginLeft: 12,
},
});

View File

@ -0,0 +1,332 @@
import { useState, useRef } from 'react';
import {
View,
Text,
TextInput,
Pressable,
Image,
StyleSheet,
ActivityIndicator,
Platform,
Alert,
} from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { supabase } from '../../lib/supabase';
type ReplyTo = { id: string; nickname: string; content: string };
export type SendPayload = {
content: string;
replyToId?: string;
attachmentUrl?: string;
attachmentType?: string;
attachmentName?: string;
};
type Props = {
replyTo: ReplyTo | null;
sending?: boolean;
placeholder?: string;
disabled?: boolean;
onSend: (data: SendPayload) => void;
onCancelReply: () => void;
};
export function ChatInput({
replyTo,
sending,
placeholder,
disabled,
onSend,
onCancelReply,
}: Props) {
const { t } = useTranslation();
const [text, setText] = useState('');
const [attachment, setAttachment] = useState<{
uri: string;
name: string;
isImage: boolean;
} | null>(null);
const [uploading, setUploading] = useState(false);
const inputRef = useRef<TextInput>(null);
const hasContent = text.trim().length > 0 || attachment !== null;
async function pickImage() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Foto-Zugriff', 'Bitte Foto-Zugriff in den Einstellungen erlauben.');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.8,
});
if (!result.canceled && result.assets[0]?.uri) {
const a = result.assets[0];
setAttachment({
uri: a.uri,
name: a.fileName ?? `image-${Date.now()}.jpg`,
isImage: true,
});
}
}
function clearAttachment() {
setAttachment(null);
}
async function uploadAttachment(): Promise<{
url: string;
type: string;
name: string;
} | null> {
if (!attachment) return null;
try {
setUploading(true);
const ext = attachment.name.split('.').pop() || 'jpg';
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
const base64 = await FileSystem.readAsStringAsync(attachment.uri, {
encoding: FileSystem.EncodingType.Base64,
});
const arrayBuffer = decodeBase64(base64);
const { error } = await supabase.storage
.from('chat-attachments')
.upload(path, arrayBuffer, {
cacheControl: '3600',
upsert: false,
contentType: attachment.isImage ? 'image/jpeg' : 'application/octet-stream',
});
if (error) throw error;
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
return {
url: data.publicUrl,
type: attachment.isImage ? 'image' : 'file',
name: attachment.name,
};
} catch (err: any) {
Alert.alert(t('chat.upload_failed'), err?.message ?? '');
return null;
} finally {
setUploading(false);
}
}
async function handleSend() {
const content = text.trim();
if (!content && !attachment) return;
let attachmentMeta: { url: string; type: string; name: string } | null = null;
if (attachment) {
attachmentMeta = await uploadAttachment();
if (!attachmentMeta) return;
}
onSend({
content,
replyToId: replyTo?.id,
attachmentUrl: attachmentMeta?.url,
attachmentType: attachmentMeta?.type,
attachmentName: attachmentMeta?.name,
});
setText('');
setAttachment(null);
}
return (
<View style={styles.container}>
{/* Reply preview */}
{replyTo && (
<View style={styles.replyBar}>
<Ionicons name="arrow-undo" size={14} color="#007AFF" style={{ marginRight: 6 }} />
<View style={{ flex: 1 }}>
<Text style={styles.replyName} numberOfLines={1}>
{t('chat.reply_to')} {replyTo.nickname}
</Text>
<Text style={styles.replyContent} numberOfLines={1}>
{replyTo.content || '…'}
</Text>
</View>
<Pressable hitSlop={10} onPress={onCancelReply}>
<Ionicons name="close" size={16} color="#737373" />
</Pressable>
</View>
)}
{/* Attachment preview */}
{attachment && (
<View style={styles.attachBar}>
{attachment.isImage ? (
<Image source={{ uri: attachment.uri }} style={styles.attachImg} />
) : (
<View style={styles.attachFileIcon}>
<Ionicons name="document" size={18} color="#737373" />
</View>
)}
<Text style={styles.attachName} numberOfLines={1}>
{attachment.name}
</Text>
<Pressable hitSlop={10} onPress={clearAttachment}>
<Ionicons name="close" size={16} color="#737373" />
</Pressable>
</View>
)}
{/* Input row */}
<View style={styles.row}>
<Pressable
style={styles.iconBtn}
onPress={pickImage}
disabled={uploading || sending || disabled}
>
<Ionicons name="image-outline" size={22} color="#737373" />
</Pressable>
<View style={styles.inputWrap}>
<TextInput
ref={inputRef}
value={text}
onChangeText={setText}
placeholder={placeholder ?? t('chat.placeholder')}
placeholderTextColor="#a3a3a3"
multiline
maxLength={2000}
editable={!sending && !disabled}
style={styles.input}
/>
</View>
<Pressable
onPress={handleSend}
disabled={!hasContent || sending || uploading || disabled}
style={[
styles.sendBtn,
{ backgroundColor: hasContent ? '#007AFF' : '#e5e5e5' },
]}
>
{sending || uploading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="send" size={16} color={hasContent ? '#fff' : '#a3a3a3'} />
)}
</Pressable>
</View>
</View>
);
}
// Base64 → Uint8Array (für Supabase Storage Upload)
function decodeBase64(base64: string): Uint8Array {
const binary =
typeof atob === 'function'
? atob(base64)
: Buffer.from(base64, 'base64').toString('binary');
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#ffffff',
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: '#e5e5e5',
},
replyBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#eff6ff',
borderLeftWidth: 3,
borderLeftColor: '#007AFF',
marginHorizontal: 8,
marginTop: 6,
borderRadius: 8,
},
replyName: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#007AFF',
},
replyContent: {
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#525252',
marginTop: 1,
},
attachBar: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: '#fafafa',
marginHorizontal: 8,
marginTop: 6,
borderRadius: 8,
},
attachImg: {
width: 36,
height: 36,
borderRadius: 6,
marginRight: 8,
},
attachFileIcon: {
width: 36,
height: 36,
borderRadius: 6,
backgroundColor: '#e5e5e5',
alignItems: 'center',
justifyContent: 'center',
marginRight: 8,
},
attachName: {
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
},
row: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: 8,
paddingTop: 8,
paddingBottom: 8,
},
iconBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: 4,
},
inputWrap: {
flex: 1,
backgroundColor: '#f5f5f5',
borderRadius: 22,
paddingHorizontal: 14,
minHeight: 36,
maxHeight: 120,
justifyContent: 'center',
},
input: {
fontSize: 14,
lineHeight: 19,
fontFamily: 'Nunito_400Regular',
color: '#171717',
paddingVertical: Platform.OS === 'ios' ? 8 : 4,
},
sendBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
},
});

View File

@ -0,0 +1,282 @@
import { useState } from 'react';
import {
Modal,
View,
Text,
TextInput,
Pressable,
StyleSheet,
ActivityIndicator,
Platform,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
type Props = {
visible: boolean;
onClose: () => void;
onCreated: (room: any) => void;
};
export function CreateRoomSheet({ visible, onClose, onCreated }: Props) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [isPublic, setIsPublic] = useState(true);
const [joinMode, setJoinMode] = useState<'approval' | 'invite_only'>('approval');
const [creating, setCreating] = useState(false);
function reset() {
setName('');
setDescription('');
setIsPublic(true);
setJoinMode('approval');
}
async function create() {
const trimmed = name.trim();
if (!trimmed || creating) return;
setCreating(true);
try {
const room = await apiFetch<any>('/api/chat/rooms', {
method: 'POST',
body: {
name: trimmed,
description: description.trim() || undefined,
isPublic,
joinMode: isPublic ? 'open' : joinMode,
},
});
onCreated(room);
reset();
onClose();
} catch (err: any) {
console.error('Room erstellen fehlgeschlagen:', err.message);
} finally {
setCreating(false);
}
}
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<Pressable style={styles.sheet} onPress={() => {}}>
<View style={styles.grabber} />
<Text style={styles.title}>{t('chat.create_group')}</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder={t('chat.room_name')}
placeholderTextColor="#a3a3a3"
style={styles.input}
maxLength={60}
/>
<TextInput
value={description}
onChangeText={setDescription}
placeholder={t('chat.room_description')}
placeholderTextColor="#a3a3a3"
multiline
style={[styles.input, { height: 70, textAlignVertical: 'top' }]}
maxLength={250}
/>
{/* Public toggle */}
<Pressable
style={styles.toggleRow}
onPress={() => setIsPublic((v) => !v)}
>
<Text style={styles.toggleLabel}>{t('chat.public_room')}</Text>
<View style={[styles.toggle, isPublic && styles.toggleOn]}>
<View style={[styles.toggleKnob, isPublic && styles.toggleKnobOn]} />
</View>
</Pressable>
{/* Join mode (private only) */}
{!isPublic && (
<View style={{ marginTop: 8 }}>
<Text style={styles.subLabel}>{t('chat.join_mode')}</Text>
<View style={styles.modeRow}>
{(['approval', 'invite_only'] as const).map((mode) => (
<Pressable
key={mode}
style={[styles.modeBtn, joinMode === mode && styles.modeBtnActive]}
onPress={() => setJoinMode(mode)}
>
<Text
style={[
styles.modeBtnText,
joinMode === mode && styles.modeBtnTextActive,
]}
>
{t(`chat.join_mode_${mode === 'approval' ? 'approval' : 'invite'}`)}
</Text>
</Pressable>
))}
</View>
</View>
)}
{/* Actions */}
<View style={styles.actions}>
<Pressable onPress={onClose} style={styles.cancelBtn}>
<Text style={styles.cancelText}>{t('common.cancel')}</Text>
</Pressable>
<Pressable
onPress={create}
disabled={!name.trim() || creating}
style={[
styles.createBtn,
{ opacity: !name.trim() || creating ? 0.5 : 1 },
]}
>
{creating ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.createText}>{t('chat.create')}</Text>
)}
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 22,
borderTopRightRadius: 22,
padding: 18,
paddingBottom: Platform.OS === 'ios' ? 32 : 18,
},
grabber: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: '#d4d4d4',
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#171717',
marginBottom: 14,
},
input: {
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: '#171717',
marginBottom: 10,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 6,
marginTop: 4,
},
toggleLabel: {
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
},
toggle: {
width: 46,
height: 28,
borderRadius: 14,
backgroundColor: '#e5e5e5',
padding: 2,
justifyContent: 'center',
},
toggleOn: {
backgroundColor: '#007AFF',
},
toggleKnob: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 2,
},
toggleKnobOn: {
transform: [{ translateX: 18 }],
},
subLabel: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
marginBottom: 6,
},
modeRow: {
flexDirection: 'row',
},
modeBtn: {
flex: 1,
paddingVertical: 8,
borderRadius: 10,
borderWidth: 1,
borderColor: '#e5e5e5',
alignItems: 'center',
marginRight: 6,
},
modeBtnActive: {
backgroundColor: '#eff6ff',
borderColor: '#007AFF',
},
modeBtnText: {
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
},
modeBtnTextActive: {
color: '#007AFF',
},
actions: {
flexDirection: 'row',
marginTop: 20,
},
cancelBtn: {
flex: 1,
backgroundColor: '#f5f5f5',
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
marginRight: 6,
},
cancelText: {
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#171717',
},
createBtn: {
flex: 1,
backgroundColor: '#007AFF',
paddingVertical: 12,
borderRadius: 12,
alignItems: 'center',
marginLeft: 6,
},
createText: {
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: '#fff',
},
});

View File

@ -0,0 +1,217 @@
import { View, Text, Pressable, Image, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
export type Room = {
id: string;
name: string;
description?: string | null;
isPublic: boolean;
isDefault: boolean;
memberCount: number;
isMember: boolean;
avatarUrl?: string | null;
lastMessage?: { content: string; createdAt: string; senderName: string } | null;
};
type Props = {
room: Room;
onPress: () => void;
};
function formatTime(ts: string, justNow: string) {
const diff = Date.now() - new Date(ts).getTime();
if (diff < 60_000) return justNow;
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
return new Date(ts).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
export function RoomCard({ room, onPress }: Props) {
const { t } = useTranslation();
const initials = room.name
.split(' ')
.slice(0, 2)
.map((w) => w[0]?.toUpperCase() ?? '')
.join('');
return (
<Pressable onPress={onPress} android_ripple={{ color: '#f5f5f5' }}>
{({ pressed }) => (
<View style={[styles.row, { opacity: pressed ? 0.7 : 1 }]}>
<View
style={[
styles.avatar,
{ backgroundColor: room.isPublic ? '#eff6ff' : '#e5e5e5' },
]}
>
{room.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} />
) : !room.isPublic ? (
<Text style={styles.avatarInitials}>{initials}</Text>
) : (
<Ionicons name="globe-outline" size={20} color="#007AFF" />
)}
</View>
<View style={styles.info}>
<View style={styles.headerRow}>
<Text style={styles.name} numberOfLines={1}>
{room.name}
</Text>
{room.isDefault && (
<View style={styles.defaultBadge}>
<Text style={styles.defaultBadgeText}>Standard</Text>
</View>
)}
{room.lastMessage && (
<Text style={styles.time}>
{formatTime(room.lastMessage.createdAt, t('chat.just_now'))}
</Text>
)}
</View>
<View style={styles.footerRow}>
<View style={styles.footerTextWrap}>
{room.lastMessage ? (
<Text style={styles.lastMessage} numberOfLines={1}>
<Text style={{ fontFamily: 'Nunito_700Bold' }}>
{room.lastMessage.senderName}:{' '}
</Text>
{room.lastMessage.content}
</Text>
) : room.description ? (
<Text style={styles.description} numberOfLines={1}>
{room.description}
</Text>
) : null}
</View>
<View style={styles.metaPill}>
<Ionicons name="people" size={11} color="#a3a3a3" />
<Text style={styles.memberCount}>{room.memberCount}</Text>
</View>
{!room.isMember && (
<View style={styles.joinBadge}>
<Text style={styles.joinBadgeText}>{t('chat.join')}</Text>
</View>
)}
</View>
</View>
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 11,
backgroundColor: '#fff',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#f5f5f5',
},
avatar: {
width: 42,
height: 42,
borderRadius: 21,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
marginRight: 10,
},
avatarImg: {
width: 42,
height: 42,
},
avatarInitials: {
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: '#525252',
},
info: {
flex: 1,
minWidth: 0,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
},
footerRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 3,
},
footerTextWrap: {
flex: 1,
minWidth: 0,
},
metaPill: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: 8,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 8,
backgroundColor: '#f5f5f5',
},
name: {
fontSize: 14,
fontFamily: 'Nunito_700Bold',
color: '#171717',
flexShrink: 1,
},
defaultBadge: {
marginLeft: 6,
paddingHorizontal: 6,
paddingVertical: 1,
backgroundColor: '#eff6ff',
borderRadius: 8,
},
defaultBadgeText: {
fontSize: 9,
fontFamily: 'Nunito_700Bold',
color: '#007AFF',
},
lastMessage: {
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#737373',
},
description: {
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
},
right: {
alignItems: 'flex-end',
marginLeft: 8,
},
memberCount: {
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: '#737373',
marginLeft: 3,
},
time: {
fontSize: 10,
fontFamily: 'Nunito_500Medium',
color: '#a3a3a3',
marginLeft: 'auto',
paddingLeft: 6,
},
joinBadge: {
marginLeft: 6,
paddingHorizontal: 8,
paddingVertical: 3,
backgroundColor: '#eff6ff',
borderRadius: 10,
},
joinBadgeText: {
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: '#007AFF',
},
});

View File

@ -0,0 +1,605 @@
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Easing,
KeyboardAvoidingView,
Linking,
Modal,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect, detectProvider, type MailProvider } from '../../hooks/useMailConnect';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.65;
type Props = {
visible: boolean;
onClose: () => void;
onSuccess: () => void;
};
type ProviderConfig = {
id: MailProvider;
labelKey: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
color: string;
guideKey: string;
guideUrl: string;
};
const PROVIDERS: ProviderConfig[] = [
{
id: 'gmail',
labelKey: 'mail.provider_gmail',
icon: 'mail',
color: '#EA4335',
guideKey: 'mail.app_password_guide_gmail',
guideUrl: 'https://myaccount.google.com/apppasswords',
},
{
id: 'icloud',
labelKey: 'mail.provider_icloud',
icon: 'cloud',
color: '#007AFF',
guideKey: 'mail.app_password_guide_icloud',
guideUrl: 'https://appleid.apple.com/account/manage',
},
{
id: 'outlook',
labelKey: 'mail.provider_outlook',
icon: 'mail-open',
color: '#0078D4',
guideKey: 'mail.app_password_guide_outlook',
guideUrl: 'https://account.microsoft.com/security',
},
{
id: 'yahoo',
labelKey: 'mail.provider_yahoo',
icon: 'at',
color: '#7C3AED',
guideKey: 'mail.app_password_guide_yahoo',
guideUrl: 'https://login.yahoo.com/account/security',
},
{
id: 'gmx',
labelKey: 'mail.provider_gmx',
icon: 'mail-unread',
color: '#E87A22',
guideKey: 'mail.app_password_guide_gmx',
guideUrl: 'https://www.gmx.net/mail/security',
},
{
id: 'other',
labelKey: 'mail.provider_other',
icon: 'server',
color: '#737373',
guideKey: 'mail.app_password_guide_other',
guideUrl: '',
},
];
/**
* Bottom-Sheet (65% Screen-Höhe) zum Verbinden eines Postfachs.
*
* Zwei Ansichten im selben Sheet:
* 1. Provider-Grid (6 Tiles)
* 2. Formular-View: Email + App-Passwort + Guide-Link (nach Provider-Tap)
*/
export function ConnectMailSheet({ visible, onClose, onSuccess }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { connect, connecting, error: connectError } = useMailConnect();
const [view, setView] = useState<'grid' | 'form'>('grid');
const [selectedProvider, setSelectedProvider] = useState<ProviderConfig | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
translateY.setValue(SHEET_HEIGHT);
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: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, translateY, backdropOpacity]);
function handleClose() {
setView('grid');
setSelectedProvider(null);
setEmail('');
setPassword('');
setPasswordVisible(false);
setFormError(null);
onClose();
}
function handleProviderSelect(provider: ProviderConfig) {
setSelectedProvider(provider);
setView('form');
setFormError(null);
}
function handleBack() {
setView('grid');
setSelectedProvider(null);
setFormError(null);
}
async function handleConnect() {
if (!email.trim() || !password.trim()) {
setFormError(t('mail.form_fields_required'));
return;
}
setFormError(null);
const body: Parameters<typeof connect>[0] = { email: email.trim(), password };
// Für "other" Provider: User muss imapHost selbst eingeben — aktuell nicht
// unterstützt in dieser Sheet-Version. Custom-IMAP bleibt TODO für Phase 11.
// Provider-Detection passiert server-seitig via Email-Domain.
const result = await connect(body);
if (result.ok) {
handleClose();
onSuccess();
} else {
setFormError(result.error ?? t('mail.connect_failed'));
}
}
// Wenn User Email tippt → Provider-Icon in Echtzeit updaten
const detectedProvider = email.includes('@') ? detectProvider(email) : null;
const currentProvider = selectedProvider ?? null;
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={handleClose}>
{/* Backdrop */}
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={handleClose} />
</Animated.View>
{/* Sheet */}
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: SHEET_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
transform: [{ translateY }],
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Drag-Handle */}
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: '#d4d4d4' }} />
</View>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
{view === 'form' ? (
<Pressable onPress={handleBack} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.back')}
</Text>
</Pressable>
) : (
<Pressable onPress={handleClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.cancel')}
</Text>
</Pressable>
)}
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{view === 'form' && currentProvider
? t(currentProvider.labelKey)
: t('mail.connect_sheet_title')}
</Text>
<View style={{ width: 60 }} />
</View>
{/* Content */}
{view === 'grid' ? (
<ProviderGrid providers={PROVIDERS} onSelect={handleProviderSelect} t={t} />
) : (
<FormView
provider={currentProvider}
detectedProvider={detectedProvider}
email={email}
onEmailChange={(v) => { setEmail(v); setFormError(null); }}
password={password}
onPasswordChange={(v) => { setPassword(v); setFormError(null); }}
passwordVisible={passwordVisible}
onTogglePasswordVisible={() => setPasswordVisible((p) => !p)}
error={formError ?? connectError}
connecting={connecting}
onConnect={handleConnect}
insets={insets}
t={t}
/>
)}
</KeyboardAvoidingView>
</Animated.View>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Provider-Grid
// ---------------------------------------------------------------------------
function ProviderGrid({
providers,
onSelect,
t,
}: {
providers: ProviderConfig[];
onSelect: (p: ProviderConfig) => void;
t: (key: string) => string;
}) {
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 12 }}
showsVerticalScrollIndicator={false}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
marginBottom: 4,
lineHeight: 18,
}}
>
{t('mail.connect_sheet_subtitle')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
{providers.map((p) => (
<Pressable
key={p.id}
onPress={() => onSelect(p)}
style={({ pressed }) => ({
width: '47%',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: '#f9f9f9',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 14,
opacity: pressed ? 0.7 : 1,
})}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: p.color + '18',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={p.icon} size={18} color={p.color} />
</View>
<View style={{ flex: 1 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
numberOfLines={1}
>
{t(p.labelKey)}
</Text>
</View>
<Ionicons name="chevron-forward" size={14} color="#d4d4d4" />
</Pressable>
))}
</View>
</ScrollView>
);
}
// ---------------------------------------------------------------------------
// Sub-View: Formular (Email + App-Passwort)
// ---------------------------------------------------------------------------
type FormViewProps = {
provider: ProviderConfig | null;
detectedProvider: MailProvider | null;
email: string;
onEmailChange: (v: string) => void;
password: string;
onPasswordChange: (v: string) => void;
passwordVisible: boolean;
onTogglePasswordVisible: () => void;
error: string | null;
connecting: boolean;
onConnect: () => void;
insets: ReturnType<typeof useSafeAreaInsets>;
t: (key: string) => string;
};
function FormView({
provider,
email,
onEmailChange,
password,
onPasswordChange,
passwordVisible,
onTogglePasswordVisible,
error,
connecting,
onConnect,
insets,
t,
}: FormViewProps) {
const canConnect = email.trim().length > 0 && password.trim().length > 0 && !connecting;
return (
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, gap: 14 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* App-Password-Guide-Hinweis */}
{provider && provider.id !== 'other' && (
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#f0f7ff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bfdbfe',
}}
>
<Ionicons name="information-circle" size={18} color="#1d4ed8" style={{ marginTop: 1 }} />
<View style={{ flex: 1, gap: 4 }}>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#1e3a8a',
}}
>
{t('mail.app_password_required_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#1d4ed8',
lineHeight: 17,
}}
>
{t(provider.guideKey)}
</Text>
{provider.guideUrl.length > 0 && (
<Pressable onPress={() => Linking.openURL(provider.guideUrl)}>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#007AFF',
marginTop: 2,
}}
>
{t('mail.app_password_open_link')}
</Text>
</Pressable>
)}
</View>
</View>
)}
{/* Email-Input */}
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
marginBottom: 6,
}}
>
{t('mail.form_email_label')}
</Text>
<TextInput
value={email}
onChangeText={onEmailChange}
placeholder={t('mail.form_email_placeholder')}
placeholderTextColor="#a3a3a3"
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="next"
style={{
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
}}
/>
</View>
{/* Passwort-Input */}
<View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
marginBottom: 6,
}}
>
{t('mail.form_password_label')}
</Text>
<View style={{ position: 'relative' }}>
<TextInput
value={password}
onChangeText={onPasswordChange}
placeholder={t('mail.form_password_placeholder')}
placeholderTextColor="#a3a3a3"
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={onConnect}
style={{
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
paddingRight: 46,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
}}
/>
<Pressable
onPress={onTogglePasswordVisible}
hitSlop={8}
style={{
position: 'absolute',
right: 12,
top: 0,
bottom: 0,
justifyContent: 'center',
}}
>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color="#a3a3a3"
/>
</Pressable>
</View>
</View>
{/* Datenschutz-Hinweis */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: '#f0fdf4',
borderRadius: 12,
borderWidth: 1,
borderColor: '#bbf7d0',
}}
>
<Ionicons name="shield-checkmark" size={16} color="#16a34a" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#166534',
lineHeight: 17,
}}
>
{t('mail.form_privacy_note')}
</Text>
</View>
{/* Error */}
{error && (
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
}}
>
{error}
</Text>
)}
{/* Connect-Button */}
<Pressable
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,
})}
>
{connecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.form_connect_btn')}
</Text>
)}
</Pressable>
</ScrollView>
);
}

View File

@ -0,0 +1,249 @@
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Animated,
Dimensions,
Easing,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
Text,
TextInput,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailConnect } from '../../hooks/useMailConnect';
const SCREEN_HEIGHT = Dimensions.get('window').height;
const SHEET_HEIGHT = SCREEN_HEIGHT * 0.5;
type Props = {
visible: boolean;
email: string;
onClose: () => void;
onSuccess: () => void;
};
/**
* Sheet zum Aktualisieren des App-Passworts eines bereits verbundenen Postfachs.
* Nutzt POST /api/mail/connect (upsert) Backend ersetzt verschlüsseltes Passwort.
*/
export function EditMailAccountSheet({ visible, email, onClose, onSuccess }: Props) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const { connect, connecting, error: connectError } = useMailConnect();
const [password, setPassword] = useState('');
const [passwordVisible, setPasswordVisible] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const translateY = useRef(new Animated.Value(SHEET_HEIGHT)).current;
const backdropOpacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
setPassword('');
setPasswordVisible(false);
setFormError(null);
translateY.setValue(SHEET_HEIGHT);
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: 220,
useNativeDriver: true,
}),
]).start();
}
}, [visible, translateY, backdropOpacity]);
async function handleSave() {
if (!password.trim()) {
setFormError(t('mail.form_fields_required'));
return;
}
setFormError(null);
const result = await connect({ email, password });
if (result.ok) {
onClose();
onSuccess();
} else {
setFormError(result.error ?? t('mail.connect_failed'));
}
}
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose}>
<Animated.View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
opacity: backdropOpacity,
}}
>
<Pressable style={{ flex: 1 }} onPress={onClose} />
</Animated.View>
<Animated.View
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: SHEET_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
transform: [{ translateY }],
}}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{ flex: 1 }}
>
{/* Drag-Handle */}
<View style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 4 }}>
<View style={{ width: 36, height: 4, borderRadius: 2, backgroundColor: '#d4d4d4' }} />
</View>
{/* Header */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 6,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
}}
>
<Pressable onPress={onClose} hitSlop={10}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_400Regular', color: '#525252' }}>
{t('common.cancel')}
</Text>
</Pressable>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t('mail.edit_account_title')}
</Text>
<View style={{ width: 60 }} />
</View>
<View style={{ flex: 1, padding: 20, gap: 14 }}>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
lineHeight: 18,
}}
>
{t('mail.edit_account_subtitle', { email })}
</Text>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f5f5f5',
borderRadius: 12,
paddingHorizontal: 14,
gap: 10,
}}
>
<Ionicons name="lock-closed-outline" size={16} color="#a3a3a3" />
<TextInput
value={password}
onChangeText={(v) => {
setPassword(v);
setFormError(null);
}}
placeholder={t('mail.app_password_placeholder')}
placeholderTextColor="#a3a3a3"
secureTextEntry={!passwordVisible}
autoCapitalize="none"
autoCorrect={false}
style={{
flex: 1,
paddingVertical: 14,
fontSize: 15,
fontFamily: 'Nunito_400Regular',
color: '#0a0a0a',
}}
/>
<Pressable onPress={() => setPasswordVisible((p) => !p)} hitSlop={8}>
<Ionicons
name={passwordVisible ? 'eye-off-outline' : 'eye-outline'}
size={18}
color="#737373"
/>
</Pressable>
</View>
{(formError ?? connectError) && (
<View
style={{
backgroundColor: '#fef2f2',
borderRadius: 10,
padding: 12,
flexDirection: 'row',
gap: 8,
alignItems: 'flex-start',
}}
>
<Ionicons name="alert-circle" size={16} color="#dc2626" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#dc2626',
}}
>
{formError ?? connectError}
</Text>
</View>
)}
<Pressable
onPress={handleSave}
disabled={!password.trim() || connecting}
style={({ pressed }) => ({
marginTop: 4,
paddingVertical: 14,
borderRadius: 12,
backgroundColor: !password.trim() || connecting ? '#bfdbfe' : '#007AFF',
alignItems: 'center',
opacity: pressed ? 0.85 : 1,
})}
>
{connecting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('mail.edit_account_save')}
</Text>
)}
</Pressable>
<View style={{ height: insets.bottom }} />
</View>
</KeyboardAvoidingView>
</Animated.View>
</Modal>
);
}

View File

@ -0,0 +1,391 @@
import { useState } from 'react';
import {
ActivityIndicator,
LayoutAnimation,
Platform,
Pressable,
Text,
UIManager,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { ConfirmAlert } from '../ConfirmAlert';
import { EditMailAccountSheet } from './EditMailAccountSheet';
import { useMailInterval } from '../../hooks/useMailInterval';
import type { MailAccount } from '../../hooks/useMailStatus';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
type Props = {
account: MailAccount;
plan: 'free' | 'pro' | 'legend';
expanded: boolean;
onToggle: () => void;
onDisconnect: (id: string) => Promise<void>;
onIntervalChanged: () => void;
onEditSuccess: () => void;
disconnecting?: boolean;
};
function resolveProviderIcon(provider: string): {
icon: React.ComponentProps<typeof Ionicons>['name'];
color: string;
} {
const p = provider.toLowerCase();
if (p.includes('gmail') || p.includes('google')) return { icon: 'mail', color: '#EA4335' };
if (p.includes('icloud') || p.includes('apple')) return { icon: 'cloud', color: '#007AFF' };
if (p.includes('outlook') || p.includes('hotmail') || p.includes('microsoft'))
return { icon: 'mail-open', color: '#0078D4' };
if (p.includes('yahoo')) return { icon: 'at', color: '#7C3AED' };
if (p.includes('gmx') || p.includes('web.de'))
return { icon: 'mail-unread', color: '#E87A22' };
return { icon: 'server', color: '#737373' };
}
function formatRelativeTime(iso: string | null, t: (k: string) => string): string {
if (!iso) return t('mail.account_never_scanned');
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 2) return t('mail.account_just_now');
if (mins < 60) return `${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
const INTERVAL_OPTIONS_BY_PLAN: Record<'free' | 'pro' | 'legend', number[]> = {
free: [4],
pro: [1, 4, 8],
legend: [1, 4, 8],
};
// Solid styles outside of render — no gap, no callback layout.
const HEADER_ROW = {
flexDirection: 'row' as const,
alignItems: 'center' as const,
paddingHorizontal: 14,
paddingVertical: 14,
};
const ACTION_BTN_BASE = {
flex: 1,
flexDirection: 'row' as const,
alignItems: 'center' as const,
justifyContent: 'center' as const,
paddingVertical: 12,
borderRadius: 10,
};
export function MailAccountCard({
account,
plan,
expanded,
onToggle,
onDisconnect,
onIntervalChanged,
onEditSuccess,
disconnecting,
}: Props) {
const { t } = useTranslation();
const [confirmVisible, setConfirmVisible] = useState(false);
const [editVisible, setEditVisible] = useState(false);
const { setInterval, updating } = useMailInterval();
const { icon, color } = resolveProviderIcon(account.provider);
const isLegend = plan === 'legend';
const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan];
function handleToggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
onToggle();
}
async function handleSetInterval(value: number) {
const res = await setInterval(account.id, value);
if (res.ok) onIntervalChanged();
}
return (
<>
<View
style={{
backgroundColor: '#fff',
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
overflow: 'hidden',
}}
>
{/* ── Header ── */}
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
<View style={HEADER_ROW}>
<View
style={{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: color + '18',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name={icon} size={19} color={color} />
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
numberOfLines={1}
>
{account.email}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
<View
style={{
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: account.isActive ? '#16a34a' : '#dc2626',
marginRight: 5,
}}
/>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_600SemiBold',
color: account.isActive ? '#16a34a' : '#dc2626',
marginRight: 8,
}}
>
{account.isActive
? isLegend
? t('mail.live')
: t('mail.account_active')
: t('mail.account_inactive')}
</Text>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}
numberOfLines={1}
>
· {formatRelativeTime(account.lastScannedAt, t)}
</Text>
</View>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={18}
color="#a3a3a3"
/>
</View>
</Pressable>
{/* ── Body ── */}
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5', padding: 14 }}>
{/* Big stat: Blocked */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#fef2f2',
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 12,
marginBottom: 12,
}}
>
<Ionicons name="shield-checkmark" size={20} color="#dc2626" style={{ marginRight: 10 }} />
<View style={{ flex: 1 }}>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}
>
{t('mail.account_stat_blocked')}
</Text>
<Text
style={{
fontSize: 22,
fontFamily: 'Nunito_800ExtraBold',
color: '#dc2626',
marginTop: 1,
}}
>
{account.totalBlocked.toLocaleString()}
</Text>
</View>
<Text
style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#737373' }}
>
{t('mail.account_of_scanned', {
scanned: account.totalScanned.toLocaleString(),
})}
</Text>
</View>
{/* Scan Mode */}
{isLegend ? (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f0fdf4',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 12,
}}
>
<Ionicons name="flash" size={14} color="#16a34a" style={{ marginRight: 8 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#16a34a',
}}
>
{t('mail.realtime_desc')}
</Text>
</View>
) : (
<View style={{ marginBottom: 12 }}>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_600SemiBold',
color: '#737373',
textTransform: 'uppercase',
letterSpacing: 0.6,
marginBottom: 6,
}}
>
{t('mail.scan_interval_label')}
</Text>
<View style={{ flexDirection: 'row' }}>
{intervalOptions.map((opt, idx) => {
const active = account.scanInterval === opt;
const disabled = plan === 'free' || updating === account.id;
return (
<Pressable
key={opt}
disabled={disabled}
onPress={() => handleSetInterval(opt)}
style={{
flex: 1,
paddingVertical: 9,
borderRadius: 10,
alignItems: 'center',
backgroundColor: active ? '#007AFF' : '#f5f5f5',
marginLeft: idx === 0 ? 0 : 6,
opacity: disabled && !active ? 0.5 : 1,
}}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: active ? '#fff' : '#525252',
}}
>
{opt}h
</Text>
</Pressable>
);
})}
</View>
{plan === 'free' && (
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 6,
}}
>
{t('mail.free_scan_interval_hint')}
</Text>
)}
</View>
)}
{/* Action Row */}
<View style={{ flexDirection: 'row' }}>
<Pressable
onPress={() => setEditVisible(true)}
style={{ ...ACTION_BTN_BASE, backgroundColor: '#f5f5f5', marginRight: 6 }}
>
<Ionicons
name="key-outline"
size={14}
color="#525252"
style={{ marginRight: 6 }}
/>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#525252' }}
numberOfLines={1}
>
{t('mail.account_change_password')}
</Text>
</Pressable>
<Pressable
onPress={() => setConfirmVisible(true)}
disabled={disconnecting}
style={{
...ACTION_BTN_BASE,
backgroundColor: '#fef2f2',
marginLeft: 6,
opacity: disconnecting ? 0.6 : 1,
}}
>
{disconnecting ? (
<ActivityIndicator size="small" color="#dc2626" />
) : (
<>
<Ionicons
name="trash-outline"
size={14}
color="#dc2626"
style={{ marginRight: 6 }}
/>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}
numberOfLines={1}
>
{t('mail.disconnect')}
</Text>
</>
)}
</Pressable>
</View>
</View>
)}
</View>
<ConfirmAlert
visible={confirmVisible}
title={t('mail.account_disconnect_confirm_title')}
message={t('mail.account_disconnect_confirm_message', { email: account.email })}
confirmLabel={t('mail.account_disconnect_confirm_btn')}
destructive
icon="trash"
iconColor="#FF3B30"
onConfirm={async () => {
setConfirmVisible(false);
await onDisconnect(account.id);
}}
onCancel={() => setConfirmVisible(false)}
/>
<EditMailAccountSheet
visible={editVisible}
email={account.email}
onClose={() => setEditVisible(false)}
onSuccess={onEditSuccess}
/>
</>
);
}

View File

@ -0,0 +1,218 @@
import {
LayoutAnimation,
Platform,
Pressable,
Text,
UIManager,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useMailResults, type MailBlockedItem } from '../../hooks/useMailResults';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
type Props = {
expanded: boolean;
onToggle: () => void;
};
function formatDate(iso: string, t: (k: string) => string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 2) return t('mail.account_just_now');
if (mins < 60) return `${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
export function MailActivityLog({ expanded, onToggle }: Props) {
const { t } = useTranslation();
const { results, total, loading, refresh } = useMailResults(expanded);
function handleToggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
onToggle();
}
return (
<View
style={{
backgroundColor: '#fff',
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
overflow: 'hidden',
}}
>
<Pressable onPress={handleToggle} android_ripple={{ color: '#f5f5f5' }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 14,
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#fef2f2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name="trash" size={15} color="#dc2626" />
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}
numberOfLines={1}
>
{t('mail.activity_log_title')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 2,
}}
numberOfLines={1}
>
{t('mail.activity_log_subtitle')}
</Text>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={18}
color="#a3a3a3"
/>
</View>
</Pressable>
{expanded && (
<View style={{ borderTopWidth: 1, borderTopColor: '#f5f5f5' }}>
{loading && results.length === 0 ? (
<View style={{ padding: 20, alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
{t('mail.loading')}
</Text>
</View>
) : results.length === 0 ? (
<View style={{ padding: 24, alignItems: 'center' }}>
<Ionicons name="checkmark-circle-outline" size={28} color="#16a34a" />
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: '#525252',
marginTop: 6,
}}
>
{t('mail.activity_log_empty')}
</Text>
</View>
) : (
<>
{results.slice(0, 10).map((item) => (
<ActivityItem key={item.id} item={item} t={t} />
))}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: '#fafafa',
}}
>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#a3a3a3' }}>
{total > 10
? t('mail.activity_log_more', { count: total - 10 })
: t('mail.activity_log_count', { count: total })}
</Text>
<Pressable onPress={refresh} hitSlop={8}>
<Ionicons name="refresh" size={14} color="#737373" />
</Pressable>
</View>
</>
)}
</View>
)}
</View>
);
}
function ActivityItem({
item,
t,
}: {
item: MailBlockedItem;
t: (k: string, opts?: any) => string;
}) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: '#fafafa',
}}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
backgroundColor: '#fef2f2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
marginTop: 1,
}}
>
<Ionicons name="close" size={12} color="#dc2626" />
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}
numberOfLines={1}
>
{item.subject || t('mail.activity_no_subject')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: '#737373',
marginTop: 1,
}}
numberOfLines={1}
>
{item.sender_name || item.sender_email}
</Text>
</View>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 2,
}}
>
{formatDate(item.received_at, t)}
</Text>
</View>
);
}

View File

@ -0,0 +1,100 @@
import { Pressable, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
type Props = {
onConnectPress: () => void;
};
/**
* Leerer Zustand wenn kein Postfach verbunden ist.
* Hero-CTA öffnet ConnectMailSheet.
*/
export function MailEmptyState({ onConnectPress }: Props) {
const { t } = useTranslation();
return (
<View
style={{
backgroundColor: '#fff',
borderRadius: 20,
borderWidth: 1,
borderColor: '#e5e5e5',
padding: 28,
alignItems: 'center',
}}
>
{/* Icon-Circle */}
<View
style={{
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: '#eff6ff',
borderWidth: 1,
borderColor: '#bfdbfe',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
}}
>
<Ionicons name="mail-open-outline" size={34} color="#3b82f6" />
</View>
<Text
style={{
fontSize: 17,
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
textAlign: 'center',
marginBottom: 8,
}}
>
{t('mail.empty_state_title')}
</Text>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: '#737373',
textAlign: 'center',
lineHeight: 19,
marginBottom: 20,
}}
>
{t('mail.empty_state_subtitle')}
</Text>
{/* Privacy-Punkte */}
<View style={{ alignSelf: 'stretch', gap: 8, marginBottom: 22 }}>
{(['privacy_1', 'privacy_2', 'privacy_3'] as const).map((key) => (
<View key={key} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Ionicons name="checkmark-circle" size={15} color="#16a34a" />
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#525252', flex: 1 }}>
{t(`mail.${key}`)}
</Text>
</View>
))}
</View>
{/* CTA */}
<Pressable
onPress={onConnectPress}
style={({ pressed }) => ({
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>
</Pressable>
</View>
);
}

View File

@ -0,0 +1,95 @@
import { Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
type Props = {
totalBlocked: number;
accountCount: number;
isLegend?: boolean;
};
/**
* Kompakte Stats: nur "Blockiert gesamt" prominent + kleines Status-Pill.
* Kein Block-Rate, kein Next-Scan Mode steht auf den Cards.
*/
export function MailStatsRow({ totalBlocked, accountCount, isLegend }: Props) {
const { t } = useTranslation();
return (
<View
style={{
backgroundColor: '#fff',
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e5e5',
paddingHorizontal: 18,
paddingVertical: 16,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_600SemiBold',
color: '#a3a3a3',
textTransform: 'uppercase',
letterSpacing: 0.6,
}}
>
{t('mail.stats_blocked')}
</Text>
<Text
style={{
fontSize: 32,
fontFamily: 'Nunito_800ExtraBold',
color: '#dc2626',
marginTop: 2,
lineHeight: 36,
}}
>
{totalBlocked.toLocaleString()}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#737373',
marginTop: 2,
}}
>
{t('mail.stats_account_summary', { count: accountCount })}
</Text>
</View>
{/* Mode pill */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: isLegend ? '#f0fdf4' : '#eff6ff',
}}
>
<Ionicons
name={isLegend ? 'flash' : 'time-outline'}
size={13}
color={isLegend ? '#16a34a' : '#2563eb'}
style={{ marginRight: 5 }}
/>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: isLegend ? '#16a34a' : '#2563eb',
}}
>
{isLegend ? t('mail.live') : t('mail.scheduled')}
</Text>
</View>
</View>
</View>
);
}

View File

@ -0,0 +1,145 @@
// 4-7-8 Atemübung: Card (in-chat) + Drawer (bottom sheet).
import { useEffect, useRef, useState } from 'react';
import { View, Text, Pressable, Animated, StyleSheet } from 'react-native';
import { BREATH_PHASES, TOTAL_ROUNDS, type BreathState } from '../../lib/sosConstants';
import { colors } from '../../lib/theme';
type Props = { onDone: () => void; onSpeak?: (text: string) => Promise<void> | void };
export function BreathingCard({ onDone, onSpeak }: Props) {
const [breathState, setBreathState] = useState<BreathState>('idle');
const [countdown, setCountdown] = useState(3);
const [round, setRound] = useState(1);
const [phaseIndex, setPhaseIndex] = useState(0);
const [count, setCount] = useState(BREATH_PHASES[0]!.duration);
const pulse = useRef(new Animated.Value(1)).current;
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const animRef = useRef<Animated.CompositeAnimation | null>(null);
const currentPhase = BREATH_PHASES[phaseIndex]!;
function runPulse(target: number, seconds: number) {
animRef.current?.stop();
animRef.current = Animated.timing(pulse, { toValue: target, duration: seconds * 1000, useNativeDriver: true });
animRef.current.start();
}
// Countdown: visually 3 → 2 → 1 → 0 ("Los!"), then transition to active
useEffect(() => {
if (breathState !== 'countdown') return;
if (countdown > 0) {
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(t);
} else {
// Kein "Los!" sprechen — würde laufende Lyra-Antwort abbrechen
const t = setTimeout(() => setBreathState('active'), 500);
return () => clearTimeout(t);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [breathState, countdown]);
// Breathing phases with TTS guidance
useEffect(() => {
if (breathState !== 'active') return;
let remaining = currentPhase.duration;
setCount(remaining);
runPulse(currentPhase.phase === 'exhale' ? 1 : 1.22, currentPhase.duration);
// Speak only if speakLine defined — short words avoid overlap (inhale=4s, exhale=8s)
let speakTimer: ReturnType<typeof setTimeout> | null = null;
if (currentPhase.speakLine) {
speakTimer = setTimeout(() => onSpeak?.(currentPhase.speakLine!), 350);
}
timerRef.current = setInterval(() => {
remaining -= 1;
setCount(remaining);
if (remaining <= 0) {
clearInterval(timerRef.current!);
const next = phaseIndex + 1;
if (next >= BREATH_PHASES.length) {
if (round >= TOTAL_ROUNDS) {
// Lob ZUERST komplett ausspielen, DANN onDone (das triggert Lyras nächste Frage)
(async () => {
try { await onSpeak?.('Sehr gut! Du hast alle drei Runden geschafft. Wunderbar gemacht!'); } catch {}
onDone();
})();
return;
}
// Nächste Runde still starten
setRound((r) => r + 1);
setPhaseIndex(0);
} else {
setPhaseIndex(next);
}
}
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (speakTimer) clearTimeout(speakTimer);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [breathState, phaseIndex, round]);
return (
<View style={st.breathCardInner}>
{breathState === 'idle' ? (
<View style={{ alignItems: 'center', gap: 16 }}>
<Text style={st.breathTitle}>4-7-8 Atemübung</Text>
<Text style={st.breathSub}>3 Runden · beruhigt dein Nervensystem</Text>
<Pressable style={st.breathStartBtn} onPress={() => { setCountdown(3); setBreathState('countdown'); }}>
<Text style={st.breathStartTxt}>Starten</Text>
</Pressable>
</View>
) : breathState === 'countdown' ? (
<View style={{ alignItems: 'center', gap: 20 }}>
<Text style={st.breathSub}>Gleich geht's los...</Text>
<View style={[st.breathCircleLg, { borderColor: '#6366f1', backgroundColor: '#6366f118' }]}>
<Text style={st.breathCountLg}>{countdown > 0 ? countdown : '✓'}</Text>
</View>
</View>
) : (
<View style={{ alignItems: 'center', gap: 20 }}>
<Text style={st.breathRound}>Runde {round} / {TOTAL_ROUNDS}</Text>
<Animated.View style={{ transform: [{ scale: pulse }] }}>
<View style={[st.breathCircleLg, { borderColor: currentPhase.color, backgroundColor: currentPhase.color + '22' }]}>
<Text style={st.breathCountLg}>{count}</Text>
<Text style={[st.breathPhaseLabel, { color: currentPhase.color, fontSize: 18, marginTop: 4 }]}>{currentPhase.label}</Text>
</View>
</Animated.View>
</View>
)}
</View>
);
}
// ── BreathingDrawer (bottom sheet, covers input, slides up) ───────────────────
export function BreathingDrawer({ onDone, onSpeak }: Props) {
const slideAnim = useRef(new Animated.Value(500)).current;
useEffect(() => {
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, damping: 22, mass: 1, stiffness: 200 }).start();
}, []);
return (
<>
<View style={st.breathBackdrop} pointerEvents="none" />
<Animated.View style={[st.breathDrawerContainer, { transform: [{ translateY: slideAnim }] }]}>
<View style={st.breathDrawerHandle} />
<BreathingCard onDone={onDone} onSpeak={onSpeak} />
</Animated.View>
</>
);
}
const st = StyleSheet.create({
breathBackdrop: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.28)', zIndex: 20 },
breathDrawerContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 21, backgroundColor: '#ffffff', borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingBottom: 36, shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 20, elevation: 24 },
breathDrawerHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#d1d5db', alignSelf: 'center', marginTop: 14, marginBottom: 4 },
breathCardInner: { paddingHorizontal: 24, paddingTop: 20, paddingBottom: 8, alignItems: 'center', gap: 16 },
breathCircleLg: { width: 190, height: 190, borderRadius: 95, alignItems: 'center', justifyContent: 'center', borderWidth: 5 },
breathCountLg: { fontFamily: 'Nunito_800ExtraBold', fontSize: 60, color: '#111827', lineHeight: 68 },
breathTitle: { fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' },
breathSub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', textAlign: 'center' },
breathStartBtn: { borderRadius: 12, backgroundColor: colors.brandOrange, paddingHorizontal: 28, paddingVertical: 10, marginTop: 4 },
breathStartTxt: { color: '#fff', fontFamily: 'Nunito_700Bold', fontSize: 14 },
breathRound: { fontFamily: 'Nunito_600SemiBold', fontSize: 12, color: '#9ca3af' },
breathPhaseLabel: { fontFamily: 'Nunito_700Bold', fontSize: 13 },
});

View File

@ -0,0 +1,38 @@
// Bottom-Sheet mit 4 Mini-Spielen zur Ablenkung im SOS-Flow.
import { useEffect, useRef } from 'react';
import { View, Text, Pressable, Animated, StyleSheet } from 'react-native';
import { type GameType, GamePickerGrid } from './UrgeGames';
type Props = { onSelect: (game: GameType) => void; onClose: () => void };
export default function GamePickerDrawer({ onSelect, onClose }: Props) {
const slideAnim = useRef(new Animated.Value(500)).current;
useEffect(() => {
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, damping: 22, mass: 1, stiffness: 200 }).start();
}, []);
return (
<>
<Pressable style={st.backdrop} onPress={onClose} />
<Animated.View style={[st.drawerContainer, { transform: [{ translateY: slideAnim }] }]}>
<View style={st.drawerHandle} />
<View style={{ paddingHorizontal: 20, paddingTop: 12, paddingBottom: 8 }}>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: '#111827', textAlign: 'center' }}>
Wähl ein Spiel
</Text>
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', textAlign: 'center', marginTop: 4 }}>
Lenk deinen Kopf ab nur ein paar Minuten
</Text>
</View>
<View style={{ paddingHorizontal: 16, paddingTop: 8 }}>
<GamePickerGrid onSelect={onSelect} />
</View>
</Animated.View>
</>
);
}
const st = StyleSheet.create({
backdrop: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.28)', zIndex: 20 },
drawerContainer: { position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 21, backgroundColor: '#ffffff', borderTopLeftRadius: 28, borderTopRightRadius: 28, paddingBottom: 36, shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 20, elevation: 24 },
drawerHandle: { width: 40, height: 4, borderRadius: 2, backgroundColor: '#d1d5db', alignSelf: 'center', marginTop: 14, marginBottom: 4 },
});

View File

@ -0,0 +1,53 @@
// Kleine Indikator-Komponenten für den SOS-Header / Chat:
// - ThinkingDots: 3 hüpfende Punkte ("Lyra denkt nach")
// - VoiceBars: animierte Balken für Sprach-Aktivität
import { useEffect, useRef } from 'react';
import { View, Animated, StyleSheet } from 'react-native';
export function ThinkingDots() {
const anim = useRef([new Animated.Value(0), new Animated.Value(0), new Animated.Value(0)]).current;
useEffect(() => {
const animations = anim.map((a, i) =>
Animated.loop(Animated.sequence([
Animated.delay(i * 160),
Animated.timing(a, { toValue: 1, duration: 300, useNativeDriver: true }),
Animated.timing(a, { toValue: 0, duration: 300, useNativeDriver: true }),
])),
);
animations.forEach((a) => a.start());
return () => animations.forEach((a) => a.stop());
}, []);
return (
<View style={st.thinkingRow}>
{anim.map((a, i) => (
<Animated.View key={i} style={[st.thinkingDot, { transform: [{ translateY: a.interpolate({ inputRange: [0, 1], outputRange: [0, -5] }) }] }]} />
))}
</View>
);
}
export function VoiceBars({ count, baseColor }: { count: number; baseColor: string }) {
const anims = useRef(Array.from({ length: count }, () => new Animated.Value(4))).current;
useEffect(() => {
const animations = anims.map((a, i) =>
Animated.loop(Animated.sequence([
Animated.timing(a, { toValue: 4 + Math.random() * 14, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
Animated.timing(a, { toValue: 4, duration: 450 + (i % 5) * 80, useNativeDriver: false }),
])),
);
animations.forEach((a) => a.start());
return () => animations.forEach((a) => a.stop());
}, []);
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2, height: 20 }}>
{anims.map((a, i) => (
<Animated.View key={i} style={{ width: 2.5, height: a, borderRadius: 2, backgroundColor: baseColor, opacity: 0.75 }} />
))}
</View>
);
}
const st = StyleSheet.create({
thinkingRow: { flexDirection: 'row', gap: 4, paddingHorizontal: 4, paddingVertical: 2, alignItems: 'center' },
thinkingDot: { width: 7, height: 7, borderRadius: 3.5, backgroundColor: '#9ca3af' },
});

View File

@ -0,0 +1,205 @@
import { useEffect, useRef, useState } from 'react';
import {
View,
Text,
Pressable,
TextInput,
StyleSheet,
Animated,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import type { SosFeedback } from './SosFeedbackModal';
/**
* Inline-Bewertungs-Drawer (Bottom-Sheet) als Alternative zum Exit-Modal.
* Wenn der User hier bewertet, soll der Exit-Modal nicht mehr erscheinen.
*/
export function InlineRatingDrawer({
onSubmit,
onClose,
}: {
onSubmit: (feedback: SosFeedback) => Promise<void> | void;
onClose: () => void;
}) {
const slide = useRef(new Animated.Value(600)).current;
const [better, setBetter] = useState<boolean | null>(null);
const [rating, setRating] = useState(0);
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
Animated.spring(slide, {
toValue: 0,
useNativeDriver: true,
damping: 22,
mass: 1,
stiffness: 200,
}).start();
}, []);
async function submit() {
if (submitting) return;
setSubmitting(true);
try {
await onSubmit({
better,
rating: rating > 0 ? rating : null,
text: text.trim(),
});
} finally {
setSubmitting(false);
}
}
return (
<>
<Pressable style={s.backdrop} onPress={onClose} />
<Animated.View style={[s.drawer, { transform: [{ translateY: slide }] }]}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 24 : 0}
>
<View style={s.handle} />
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 8 }}
>
<View style={s.header}>
<Ionicons name="star" size={20} color="#f59e0b" />
<Text style={s.title}>Bewerte diese Session</Text>
</View>
<Text style={s.sub}>
Dein Feedback hilft uns, Lyra besser zu machen.
</Text>
<Text style={s.q}>Fühlst du dich besser?</Text>
<View style={s.btnRow}>
<Pressable
style={[s.choiceBtn, better === true && s.choiceBtnYes]}
onPress={() => setBetter(true)}
>
<Ionicons
name="checkmark-circle"
size={18}
color={better === true ? '#fff' : '#16a34a'}
/>
<Text style={[s.choiceTxt, better === true && { color: '#fff' }]}>Ja</Text>
</Pressable>
<Pressable
style={[s.choiceBtn, better === false && s.choiceBtnNo]}
onPress={() => setBetter(false)}
>
<Ionicons
name="close-circle"
size={18}
color={better === false ? '#fff' : '#dc2626'}
/>
<Text style={[s.choiceTxt, better === false && { color: '#fff' }]}>Nein</Text>
</Pressable>
</View>
<Text style={s.q}>Bewertung</Text>
<View style={s.starsRow}>
{[1, 2, 3, 4, 5].map((n) => (
<Pressable key={n} onPress={() => setRating(n)} hitSlop={6}>
<Ionicons
name={n <= rating ? 'star' : 'star-outline'}
size={32}
color={n <= rating ? '#f59e0b' : '#cbd5e1'}
/>
</Pressable>
))}
</View>
<Text style={s.q}>Bemerkung (optional)</Text>
<TextInput
style={s.textArea}
placeholder="Was war hilfreich? Was nicht?"
placeholderTextColor="#94a3b8"
multiline
value={text}
onChangeText={setText}
maxLength={500}
/>
<View style={s.actions}>
<Pressable style={s.cancelBtn} onPress={onClose}>
<Text style={s.cancelTxt}>Abbrechen</Text>
</Pressable>
<Pressable
style={[s.submitBtn, submitting && { opacity: 0.6 }]}
onPress={submit}
disabled={submitting}
>
<Text style={s.submitTxt}>{submitting ? 'Sende…' : 'Senden'}</Text>
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
</Animated.View>
</>
);
}
const s = StyleSheet.create({
backdrop: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
},
drawer: {
position: 'absolute',
left: 0, right: 0, bottom: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 24, borderTopRightRadius: 24,
paddingHorizontal: 18, paddingTop: 8, paddingBottom: 22,
maxHeight: '88%',
...Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 12 },
android: { elevation: 12 },
}),
},
handle: {
width: 40, height: 4, borderRadius: 2,
backgroundColor: '#e2e8f0',
alignSelf: 'center',
marginBottom: 12,
},
header: { flexDirection: 'row', alignItems: 'center', gap: 8 },
title: { fontFamily: 'Nunito_800ExtraBold', fontSize: 19, color: '#111827' },
sub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', marginTop: 4 },
q: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#374151', marginTop: 14 },
btnRow: { flexDirection: 'row', gap: 10, marginTop: 6 },
choiceBtn: {
flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
borderWidth: 1, borderColor: '#cbd5e1', backgroundColor: '#f1f5f9',
borderRadius: 12, paddingVertical: 11,
},
choiceBtnYes: { backgroundColor: '#16a34a', borderColor: '#16a34a' },
choiceBtnNo: { backgroundColor: '#dc2626', borderColor: '#dc2626' },
choiceTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#374151' },
starsRow: { flexDirection: 'row', justifyContent: 'center', gap: 8, marginTop: 8 },
textArea: {
marginTop: 6, backgroundColor: '#f8fafc', borderRadius: 12,
borderWidth: 1, borderColor: '#e2e8f0',
paddingHorizontal: 12, paddingVertical: 10, minHeight: 80,
fontFamily: 'Nunito_400Regular', fontSize: 14, color: '#111827',
textAlignVertical: 'top',
},
actions: { flexDirection: 'row', gap: 10, marginTop: 18 },
cancelBtn: {
flex: 1, paddingVertical: 12, borderRadius: 12,
alignItems: 'center', backgroundColor: '#f1f5f9',
},
cancelTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#475569' },
submitBtn: {
flex: 2, paddingVertical: 12, borderRadius: 12,
alignItems: 'center', backgroundColor: colors.brandOrange,
},
submitTxt: { fontFamily: 'Nunito_800ExtraBold', fontSize: 14, color: '#fff' },
});

View File

@ -0,0 +1,90 @@
// Chat-Bubble + Spezial-Cards (Spiele/Überwunden) für den SOS-Chat-Stream
// sowie GameHeader für die aktive Spiel-Session.
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { type GameType, GAME_META, GamePickerGrid } from './UrgeGames';
import { RiveAvatar, type Emotion as LyraEmotion } from '../RiveAvatar';
export type CardType = 'games' | 'overcome';
export type SosMsg = { id: string; role: 'user' | 'assistant'; content: string; cardType?: CardType; timestamp: Date };
// ── GameCard ─────────────────────────────────────────────────────────────────
export function GameCard({ onSelect }: { onSelect: (game: GameType) => void }) {
return (
<View style={st.gameCard}>
<Text style={st.gameCardTitle}>Welches Spiel?</Text>
<GamePickerGrid onSelect={onSelect} />
</View>
);
}
// ── OvercomeCard ─────────────────────────────────────────────────────────────
export function OvercomeCard() {
return (
<View style={st.overcomeCard}>
<Text style={{ fontSize: 36 }}>🎉</Text>
<Text style={st.overcomeTitle}>Gut gemacht.</Text>
<Text style={st.overcomeSub}>Du hast den Impuls überwunden.</Text>
</View>
);
}
// ── MessageRow ───────────────────────────────────────────────────────────────
type MessageRowProps = {
item: SosMsg;
onGameSelect: (game: GameType) => void;
onBreathingDone: () => void;
onSpeak?: (text: string) => Promise<void> | void;
};
export default function MessageRow({ item, onGameSelect }: MessageRowProps) {
const isUser = item.role === 'user';
if (item.cardType === 'games') return <View style={st.msgRowAssistant}><GameCard onSelect={onGameSelect} /></View>;
if (item.cardType === 'overcome') return <View style={st.msgRowAssistant}><OvercomeCard /></View>;
return (
<View style={[st.msgRow, isUser ? st.msgRowUser : st.msgRowAssistant]}>
<View style={[st.bubbleCol, isUser ? st.bubbleColUser : st.bubbleColAssistant]}>
<View style={[st.bubble, isUser ? st.bubbleUser : st.bubbleAssistant]}>
<Text style={[st.bubbleText, isUser ? st.bubbleTextUser : st.bubbleTextAssistant]}>{item.content}</Text>
</View>
</View>
</View>
);
}
// ── GameHeader ───────────────────────────────────────────────────────────────
export function GameHeader({ game, emotion, onBack }: { game: GameType; emotion: LyraEmotion; onBack: () => void }) {
const meta = GAME_META.find((g) => g.id === game);
return (
<View style={st.gameHeader}>
<Pressable style={st.backBtn} onPress={onBack} hitSlop={12}><Ionicons name="chevron-back" size={22} color="#374151" /></Pressable>
<View style={{ alignItems: 'center', flex: 1 }}>
<View style={{ transform: [{ scale: 0.65 }], marginBottom: -8 }}><RiveAvatar emotion={emotion} size="sm" /></View>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#111827', marginTop: 2 }}>{meta?.id ?? game}</Text>
</View>
<View style={{ width: 40 }} />
</View>
);
}
const st = StyleSheet.create({
msgRow: { flexDirection: 'row', marginBottom: 4, alignItems: 'flex-end' },
msgRowUser: { justifyContent: 'flex-end' },
msgRowAssistant: { justifyContent: 'flex-start', marginBottom: 6 },
bubbleCol: { maxWidth: '75%', gap: 2 },
bubbleColUser: { alignItems: 'flex-end' },
bubbleColAssistant: { alignItems: 'flex-start' },
bubble: { borderRadius: 20, paddingHorizontal: 14, paddingVertical: 9 },
bubbleUser: { backgroundColor: '#007AFF', borderBottomRightRadius: 4 },
bubbleAssistant: { backgroundColor: '#f0f0f0', borderBottomLeftRadius: 4 },
bubbleText: { fontSize: 15, lineHeight: 22 },
bubbleTextUser: { color: '#ffffff', fontFamily: 'Nunito_400Regular' },
bubbleTextAssistant: { color: '#1a1a1a', fontFamily: 'Nunito_400Regular' },
gameCard: { backgroundColor: '#f0f9ff', borderRadius: 16, borderWidth: 1, borderColor: '#bae6fd', padding: 12, maxWidth: '92%' },
gameCardTitle: { fontFamily: 'Nunito_700Bold', color: '#0369a1', fontSize: 13, marginBottom: 10 },
overcomeCard: { backgroundColor: '#f0fdf4', borderRadius: 16, borderWidth: 1, borderColor: '#86efac', padding: 16, alignItems: 'center', maxWidth: '88%', gap: 4 },
overcomeTitle: { fontFamily: 'Nunito_800ExtraBold', fontSize: 18, color: '#15803d' },
overcomeSub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#166534', textAlign: 'center' },
gameHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#f3f4f6', backgroundColor: '#fff' },
backBtn: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#f3f4f6', alignItems: 'center', justifyContent: 'center' },
});

View File

@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from 'react';
import {
View,
Text,
Pressable,
TextInput,
StyleSheet,
Animated,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
export interface ShareSuccessPayload {
text: string;
}
/**
* Bottom-Sheet zum Teilen einer Erfolgs-Story in der Community.
* AI-generierter Vorschlagstext (vom Parent via prop), editierbar.
*/
export function ShareSuccessDrawer({
initialText,
generating,
onShare,
onClose,
onRegenerate,
}: {
initialText: string;
generating: boolean;
onShare: (text: string) => Promise<void> | void;
onClose: () => void;
onRegenerate?: () => void;
}) {
const slide = useRef(new Animated.Value(600)).current;
const [text, setText] = useState(initialText);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
Animated.spring(slide, {
toValue: 0,
useNativeDriver: true,
damping: 22,
mass: 1,
stiffness: 200,
}).start();
}, []);
// Sync wenn initialText neu generiert wird
useEffect(() => {
setText(initialText);
}, [initialText]);
async function handleShare() {
if (!text.trim() || submitting) return;
setSubmitting(true);
try {
await onShare(text.trim());
} finally {
setSubmitting(false);
}
}
return (
<>
<Pressable style={s.backdrop} onPress={onClose} />
<Animated.View style={[s.drawer, { transform: [{ translateY: slide }] }]}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 24 : 0}
>
<View style={s.handle} />
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 8 }}
>
<View style={s.header}>
<Ionicons name="sparkles" size={20} color={colors.brandOrange} />
<Text style={s.title}>Erfolg teilen</Text>
</View>
<Text style={s.sub}>
Inspiriere andere dein Beitrag wird anonym in der Community gepostet.
</Text>
{generating ? (
<View style={s.loadingBox}>
<ActivityIndicator color={colors.brandOrange} />
<Text style={s.loadingTxt}>Lyra schreibt einen Vorschlag</Text>
</View>
) : (
<TextInput
style={s.textArea}
multiline
value={text}
onChangeText={setText}
maxLength={1000}
placeholder="Schreib deine Erfolgs-Geschichte…"
placeholderTextColor="#94a3b8"
/>
)}
<View style={s.row}>
{onRegenerate && (
<Pressable
style={s.secondaryBtn}
onPress={onRegenerate}
disabled={generating}
>
<Ionicons name="refresh" size={16} color="#475569" />
<Text style={s.secondaryTxt}>Neu generieren</Text>
</Pressable>
)}
<Pressable style={s.cancelBtn} onPress={onClose}>
<Text style={s.cancelTxt}>Abbrechen</Text>
</Pressable>
<Pressable
style={[s.shareBtn, (!text.trim() || submitting || generating) && s.shareBtnDisabled]}
onPress={handleShare}
disabled={!text.trim() || submitting || generating}
>
{submitting ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<>
<Ionicons name="paper-plane" size={16} color="#fff" />
<Text style={s.shareTxt}>Teilen</Text>
</>
)}
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
</Animated.View>
</>
);
}
const s = StyleSheet.create({
backdrop: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
},
drawer: {
position: 'absolute',
left: 0, right: 0, bottom: 0,
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingHorizontal: 18,
paddingTop: 8,
paddingBottom: 22,
maxHeight: '85%',
...Platform.select({
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: -4 }, shadowOpacity: 0.18, shadowRadius: 12 },
android: { elevation: 12 },
}),
},
handle: {
width: 40, height: 4, borderRadius: 2,
backgroundColor: '#e2e8f0',
alignSelf: 'center',
marginBottom: 12,
},
header: { flexDirection: 'row', alignItems: 'center', gap: 8 },
title: { fontFamily: 'Nunito_800ExtraBold', fontSize: 19, color: '#111827' },
sub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', marginTop: 4, marginBottom: 12 },
textArea: {
backgroundColor: '#f8fafc',
borderRadius: 14,
borderWidth: 1, borderColor: '#e2e8f0',
paddingHorizontal: 14, paddingVertical: 12,
minHeight: 110, maxHeight: 220,
fontFamily: 'Nunito_400Regular', fontSize: 15, color: '#111827',
textAlignVertical: 'top',
},
loadingBox: {
backgroundColor: '#f8fafc',
borderRadius: 14, borderWidth: 1, borderColor: '#e2e8f0',
paddingVertical: 28, paddingHorizontal: 14,
alignItems: 'center', gap: 10,
minHeight: 110,
justifyContent: 'center',
},
loadingTxt: { fontFamily: 'Nunito_500Medium', fontSize: 13, color: '#64748b' },
row: { flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 14, flexWrap: 'wrap' },
secondaryBtn: {
flexDirection: 'row', alignItems: 'center', gap: 6,
paddingHorizontal: 12, paddingVertical: 10,
borderRadius: 12, backgroundColor: '#f1f5f9',
borderWidth: 1, borderColor: '#cbd5e1',
},
secondaryTxt: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#475569' },
cancelBtn: {
paddingHorizontal: 14, paddingVertical: 10,
borderRadius: 12, backgroundColor: '#f1f5f9',
},
cancelTxt: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#475569' },
shareBtn: {
flex: 1, minWidth: 110,
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
backgroundColor: colors.brandOrange,
borderRadius: 12, paddingVertical: 12,
},
shareBtnDisabled: { opacity: 0.5 },
shareTxt: { fontFamily: 'Nunito_800ExtraBold', fontSize: 14, color: '#fff' },
});

View File

@ -0,0 +1,141 @@
import { useState } from 'react';
import { View, Text, Pressable, TextInput, Modal, StyleSheet, Platform, KeyboardAvoidingView, ScrollView } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
export interface SosFeedback {
better: boolean | null;
rating: number | null;
text: string;
}
export function SosFeedbackModal({
visible,
onSubmit,
onSkip,
}: {
visible: boolean;
onSubmit: (feedback: SosFeedback) => void;
onSkip: () => void;
}) {
const [better, setBetter] = useState<boolean | null>(null);
const [rating, setRating] = useState<number>(0);
const [text, setText] = useState('');
function reset() {
setBetter(null); setRating(0); setText('');
}
function submit() {
onSubmit({ better, rating: rating > 0 ? rating : null, text: text.trim() });
reset();
}
function skip() { onSkip(); reset(); }
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={skip}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={s.backdrop}
>
<ScrollView
contentContainerStyle={s.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={s.card}>
<Text style={s.title}>Wie war diese Session?</Text>
<Text style={s.sub}>Dein Feedback hilft Lyra besser zu werden.</Text>
{/* Better Yes/No */}
<Text style={s.q}>Fühlst du dich besser?</Text>
<View style={s.btnRow}>
<Pressable
style={[s.choiceBtn, better === true && s.choiceBtnYes]}
onPress={() => setBetter(true)}
>
<Ionicons name="checkmark-circle" size={18} color={better === true ? '#fff' : '#16a34a'} />
<Text style={[s.choiceTxt, better === true && { color: '#fff' }]}>Ja</Text>
</Pressable>
<Pressable
style={[s.choiceBtn, better === false && s.choiceBtnNo]}
onPress={() => setBetter(false)}
>
<Ionicons name="close-circle" size={18} color={better === false ? '#fff' : '#dc2626'} />
<Text style={[s.choiceTxt, better === false && { color: '#fff' }]}>Nein</Text>
</Pressable>
</View>
{/* Stars */}
<Text style={s.q}>Bewertung</Text>
<View style={s.starsRow}>
{[1, 2, 3, 4, 5].map((n) => (
<Pressable key={n} onPress={() => setRating(n)} hitSlop={6}>
<Ionicons
name={n <= rating ? 'star' : 'star-outline'}
size={32}
color={n <= rating ? '#f59e0b' : '#cbd5e1'}
/>
</Pressable>
))}
</View>
{/* Comment */}
<Text style={s.q}>Bemerkung (optional)</Text>
<TextInput
style={s.textArea}
placeholder="Was war hilfreich? Was nicht?"
placeholderTextColor="#94a3b8"
multiline
numberOfLines={3}
value={text}
onChangeText={setText}
maxLength={500}
/>
{/* Actions */}
<View style={s.actions}>
<Pressable style={s.skipBtn} onPress={skip}>
<Text style={s.skipTxt}>Überspringen</Text>
</Pressable>
<Pressable style={s.submitBtn} onPress={submit}>
<Text style={s.submitTxt}>Senden</Text>
</Pressable>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
</Modal>
);
}
const s = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)' },
scrollContent: { flexGrow: 1, alignItems: 'center', justifyContent: 'center', padding: 20 },
card: { width: '100%', maxWidth: 420, backgroundColor: '#fff', borderRadius: 24, padding: 22, gap: 8, ...Platform.select({ ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.25, shadowRadius: 20 }, android: { elevation: 10 } }) },
title: { fontFamily: 'Nunito_800ExtraBold', fontSize: 19, color: '#111827' },
sub: { fontFamily: 'Nunito_400Regular', fontSize: 13, color: '#6b7280', marginBottom: 8 },
q: { fontFamily: 'Nunito_700Bold', fontSize: 13, color: '#374151', marginTop: 12 },
btnRow: { flexDirection: 'row', gap: 10, marginTop: 6 },
choiceBtn: {
flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6,
borderWidth: 1, borderColor: '#cbd5e1', backgroundColor: '#f1f5f9',
borderRadius: 12, paddingVertical: 11,
},
choiceBtnYes: { backgroundColor: '#16a34a', borderColor: '#16a34a' },
choiceBtnNo: { backgroundColor: '#dc2626', borderColor: '#dc2626' },
choiceTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#374151' },
starsRow: { flexDirection: 'row', justifyContent: 'center', gap: 8, marginTop: 8 },
textArea: {
marginTop: 6, backgroundColor: '#f8fafc', borderRadius: 12,
borderWidth: 1, borderColor: '#e2e8f0',
paddingHorizontal: 12, paddingVertical: 10, minHeight: 70,
fontFamily: 'Nunito_400Regular', fontSize: 14, color: '#111827',
textAlignVertical: 'top',
},
actions: { flexDirection: 'row', gap: 10, marginTop: 18 },
skipBtn: { flex: 1, paddingVertical: 12, borderRadius: 12, alignItems: 'center', backgroundColor: '#f1f5f9' },
skipTxt: { fontFamily: 'Nunito_700Bold', fontSize: 14, color: '#475569' },
submitBtn: { flex: 2, paddingVertical: 12, borderRadius: 12, alignItems: 'center', backgroundColor: colors.brandOrange },
submitTxt: { fontFamily: 'Nunito_800ExtraBold', fontSize: 14, color: '#fff' },
});

View File

@ -0,0 +1,60 @@
import { Pressable, Text, View } from 'react-native';
import { TTS_PROVIDER_LABEL, type TtsProvider, useTtsProvider } from '../../lib/ttsProvider';
const PROVIDERS: TtsProvider[] = ['openai', 'gemini', 'google-cloud'];
export function TtsProviderToggle() {
const [current, set] = useTtsProvider();
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 6,
}}
>
<Text
style={{
fontSize: 9,
color: '#9ca3af',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginRight: 4,
}}
>
TTS
</Text>
{PROVIDERS.map((p) => {
const active = p === current;
return (
<Pressable
key={p}
onPress={() => { void set(p); }}
hitSlop={6}
style={{
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: active ? '#1f2937' : '#f9fafb',
borderWidth: 1,
borderColor: active ? '#1f2937' : '#e5e7eb',
}}
>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_700Bold',
color: active ? '#ffffff' : '#6b7280',
}}
>
{TTS_PROVIDER_LABEL[p]}
</Text>
</Pressable>
);
})}
</View>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,367 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { colors } from '../../lib/theme';
type Emotion = 'stress' | 'sadness' | 'anger' | 'empty' | 'boredom' | 'other';
type UrgeLog = {
id: string;
timestamp: string;
emotion: Emotion;
wasOvercome: boolean;
breathingDone: boolean;
};
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function emotionLabel(key: string, t: (k: string) => string): string {
const map: Record<string, string> = {
stress: t('urge.emotion_stress'),
sadness: t('urge.emotion_sadness'),
anger: t('urge.emotion_anger'),
empty: t('urge.emotion_empty'),
boredom: t('urge.emotion_boredom'),
other: t('urge.emotion_other'),
};
return map[key] ?? key;
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<View
style={{
flex: 1,
marginHorizontal: 4,
borderRadius: 12,
borderWidth: 1,
borderColor: '#f3f4f6',
backgroundColor: '#fafafa',
paddingVertical: 10,
alignItems: 'center',
}}
>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 22, color }}>{value}</Text>
<Text
style={{
marginTop: 2,
textAlign: 'center',
fontFamily: 'Nunito_400Regular',
fontSize: 11,
color: '#6b7280',
}}
>
{label}
</Text>
</View>
);
}
export function UrgeStats() {
const { t } = useTranslation();
const [logs, setLogs] = useState<UrgeLog[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const data = await apiFetch<UrgeLog[]>('/api/urge?limit=100');
setLogs(Array.isArray(data) ? data : []);
} catch {
setLogs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const weeklyStats = useMemo(() => {
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const thisWeek = logs.filter((log) => new Date(log.timestamp).getTime() > weekAgo);
return {
total: thisWeek.length,
overcome: thisWeek.filter((log) => log.wasOvercome).length,
breathingDone: thisWeek.filter((log) => log.breathingDone).length,
};
}, [logs]);
const patterns = useMemo(() => {
if (logs.length < 5) return null;
const weekday = new Array(7).fill(0) as number[];
const timeBlockCounts = [0, 0, 0, 0] as number[];
const emotionCount: Record<string, number> = {};
for (const log of logs) {
const d = new Date(log.timestamp);
const jsDay = d.getDay();
const mondayIndex = jsDay === 0 ? 6 : jsDay - 1;
weekday[mondayIndex]!++;
const hour = d.getHours();
if (hour >= 6 && hour < 12) timeBlockCounts[0]!++;
else if (hour >= 12 && hour < 18) timeBlockCounts[1]!++;
else if (hour >= 18 && hour < 23) timeBlockCounts[2]!++;
else timeBlockCounts[3]!++;
emotionCount[log.emotion] = (emotionCount[log.emotion] ?? 0) + 1;
}
const maxWeekday = Math.max(...weekday, 1);
const maxTime = Math.max(...timeBlockCounts, 1);
const topEmotions = Object.entries(emotionCount)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
const peakDayIdx = weekday.indexOf(Math.max(...weekday));
const peakTimeIdx = timeBlockCounts.indexOf(Math.max(...timeBlockCounts));
const peakTimeLabel = ['morgens', 'mittags', 'abends', 'nachts'][peakTimeIdx] ?? '';
const peakDayLabel = peakDayIdx >= 5 ? 'am Wochenende' : `${WEEKDAY_LABELS[peakDayIdx]}s`;
return {
weekday: weekday.map((countValue, i) => ({
label: WEEKDAY_LABELS[i]!,
count: countValue,
pct: Math.round((countValue / maxWeekday) * 100),
})),
timeBlocks: [
{
emoji: '🌅',
label: t('urge.block_morning'),
count: timeBlockCounts[0]!,
pct: Math.round((timeBlockCounts[0]! / maxTime) * 100),
},
{
emoji: '☀️',
label: t('urge.block_noon'),
count: timeBlockCounts[1]!,
pct: Math.round((timeBlockCounts[1]! / maxTime) * 100),
},
{
emoji: '🌆',
label: t('urge.block_evening'),
count: timeBlockCounts[2]!,
pct: Math.round((timeBlockCounts[2]! / maxTime) * 100),
},
{
emoji: '🌙',
label: t('urge.block_night'),
count: timeBlockCounts[3]!,
pct: Math.round((timeBlockCounts[3]! / maxTime) * 100),
},
],
topEmotions,
insight: `${t('urge.pattern_insight_prefix')} ${peakDayLabel} ${peakTimeLabel}.`,
};
}, [logs, t]);
if (loading) {
return (
<View style={{ paddingVertical: 24, alignItems: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
);
}
return (
<View style={{ gap: 12 }}>
{/* Weekly counters */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text
style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827', marginBottom: 10 }}
>
{t('urge.this_week')}
</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<StatCard label={t('urge.total_urges')} value={String(weeklyStats.total)} color="#111827" />
<StatCard
label={t('urge.overcome_count')}
value={String(weeklyStats.overcome)}
color="#16a34a"
/>
<StatCard
label={t('urge.breathing_exercises')}
value={String(weeklyStats.breathingDone)}
color={colors.brandOrange}
/>
</View>
</View>
{patterns && (
<>
{/* Insight */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
flexDirection: 'row',
alignItems: 'center',
}}
>
<Ionicons name="bulb-outline" size={16} color={colors.brandOrange} />
<Text
style={{
marginLeft: 8,
color: '#374151',
fontFamily: 'Nunito_600SemiBold',
flex: 1,
}}
>
{patterns.insight}
</Text>
</View>
{/* Weekday chart */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' }}>
{t('urge.chart_weekday_title')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'flex-end', height: 70, marginTop: 10 }}>
{patterns.weekday.map((day) => (
<View key={day.label} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
width: 12,
height: day.pct > 0 ? Math.max(6, day.pct * 0.5) : 4,
borderRadius: 5,
backgroundColor:
day.pct >= 80 ? '#fb7185' : day.pct >= 50 ? '#f59e0b' : '#60a5fa',
marginBottom: 6,
}}
/>
<Text
style={{ fontFamily: 'Nunito_600SemiBold', fontSize: 10, color: '#6b7280' }}
>
{day.label}
</Text>
</View>
))}
</View>
</View>
{/* Time blocks */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' }}>
{t('urge.chart_time_title')}
</Text>
<View style={{ marginTop: 8, gap: 8 }}>
{patterns.timeBlocks.map((b) => (
<View key={b.label} style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ width: 26 }}>{b.emoji}</Text>
<Text
style={{
width: 74,
fontSize: 12,
color: '#6b7280',
fontFamily: 'Nunito_600SemiBold',
}}
>
{b.label}
</Text>
<View
style={{
flex: 1,
height: 7,
borderRadius: 4,
backgroundColor: '#e5e7eb',
}}
>
<View
style={{
width: `${b.pct}%`,
height: 7,
borderRadius: 4,
backgroundColor: '#60a5fa',
}}
/>
</View>
<Text
style={{
width: 24,
textAlign: 'right',
fontSize: 12,
color: '#6b7280',
marginLeft: 6,
}}
>
{b.count}
</Text>
</View>
))}
</View>
</View>
{/* Top emotions */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' }}>
{t('urge.chart_top_emotions')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 }}>
{patterns.topEmotions.map(([emo, c]) => (
<View
key={emo}
style={{
backgroundColor: '#f3f4f6',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
marginRight: 8,
marginBottom: 8,
}}
>
<Text
style={{ fontSize: 12, color: '#374151', fontFamily: 'Nunito_600SemiBold' }}
>
{emotionLabel(emo, t)} x{c}
</Text>
</View>
))}
</View>
</View>
</>
)}
</View>
);
}

View File

@ -0,0 +1,7 @@
export const memorySvg = `<svg height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Layer_42" data-name="Layer 42"><rect fill="#9c88ff" height="57.5" rx="12.75" width="57.5" x="3.25" y="3.25"/><g fill="#fff"><path d="m20.06 23.1a.74.74 0 0 0 .46-1l-1.59-4.64a.76.76 0 0 0 -1.42 0l-1.59 4.64a.75.75 0 0 0 1.42.49l.88-2.58.88 2.58a.77.77 0 0 0 .96.51z"/><path d="m29 42.71a.75.75 0 0 0 1-.46l.88-2.57.89 2.57a.75.75 0 0 0 1.41-.49l-1.59-4.64a.76.76 0 0 0 -1.42 0l-1.59 4.64a.74.74 0 0 0 .42.95z"/><path d="m29.33 29.34-4.17-5.83a.78.78 0 0 0 -1.22 0l-4.17 5.83a.78.78 0 0 0 0 .88l4.17 5.83a.75.75 0 0 0 1.22 0l4.17-5.83a.78.78 0 0 0 0-.88zm-4.78 5-3.25-4.56 3.25-4.54 3.25 4.54z"/><path d="m54.06 33.85-7.94-10.73a3.47 3.47 0 0 0 -2.12-4.24l-9-3.34a3.5 3.5 0 0 0 -2.68-1.28h-15.57a3.5 3.5 0 0 0 -3.5 3.5v24a3.5 3.5 0 0 0 3.5 3.5h7.17l5.2 7a3.49 3.49 0 0 0 4.89.73l19.32-14.3a3.51 3.51 0 0 0 .73-4.84zm-18.21 7.95v-24c0-.11 0-.21 0-.32l7.63 2.84a2 2 0 0 1 1.18 2.57l-8.39 22.5a2 2 0 0 1 -2.57 1.17l-3.4-1.26h2.05a3.5 3.5 0 0 0 3.5-3.5zm-19.1 2a2 2 0 0 1 -2-2v-24a2 2 0 0 1 2-2h15.6a2 2 0 0 1 1.64.85 2 2 0 0 1 .36 1.15v24a2 2 0 0 1 -2 2zm35.69-6.26-19.32 14.31a2.07 2.07 0 0 1 -1.49.37 2 2 0 0 1 -1.31-.79l-4.53-6.13h.21l7.18 2.7a3.5 3.5 0 0 0 4.5-2.06l7.84-21.08 7.34 9.92a2 2 0 0 1 -.42 2.76z"/></g></g></svg>`;
export const tictactoeSvg = `<svg height="512" viewBox="0 0 50 50" width="512" xmlns="http://www.w3.org/2000/svg"><g id="Bluenins"><path d="m22 5.0352v39.9815h18.9053a3.95272 3.95272 0 0 0 3.9527-3.9527v-32.076a3.95274 3.95274 0 0 0 -3.9527-3.9528z" fill="#3391ff"/><path d="m22 5.0352v39.9815h-13.2284a3.98174 3.98174 0 0 1 -3.9817-3.9817v-32.0181a3.98174 3.98174 0 0 1 3.9817-3.9817z" fill="#80bbff"/><path d="m43 4h-36a3.00328 3.00328 0 0 0 -3 3v36a3.00328 3.00328 0 0 0 3 3h36a3.00328 3.00328 0 0 0 3-3v-36a3.00328 3.00328 0 0 0 -3-3zm1 39a1.00094 1.00094 0 0 1 -1 1h-36a1.00094 1.00094 0 0 1 -1-1v-36a1.00094 1.00094 0 0 1 1-1h36a1.00094 1.00094 0 0 1 1 1zm-2-18a.99979.99979 0 0 1 -1 1h-15v15a1 1 0 0 1 -2 0v-15h-15a1 1 0 0 1 0-2h15v-15a1 1 0 0 1 2 0v15h15a.99979.99979 0 0 1 1 1zm-7-17a7 7 0 1 0 7 7 7.00814 7.00814 0 0 0 -7-7zm0 12a5 5 0 1 1 5-5 5.00593 5.00593 0 0 1 -5 5zm-20 8a7 7 0 1 0 7 7 7.00814 7.00814 0 0 0 -7-7zm0 12a5 5 0 1 1 5-5 5.00593 5.00593 0 0 1 -5 5zm25.707-9.293-4.2929 4.293 4.2929 4.293a.99985.99985 0 0 1 -1.414 1.414l-4.293-4.2929-4.293 4.2929a.99985.99985 0 0 1 -1.414-1.414l4.2929-4.293-4.2929-4.293a.99985.99985 0 0 1 1.414-1.414l4.293 4.2929 4.293-4.2929a.99985.99985 0 0 1 1.414 1.414zm-31.414-11.414 4.2929-4.293-4.2929-4.293a.99985.99985 0 0 1 1.414-1.414l4.293 4.2929 4.293-4.2929a.99985.99985 0 0 1 1.414 1.414l-4.2929 4.293 4.2929 4.293a.99985.99985 0 0 1 -1.414 1.414l-4.293-4.2929-4.293 4.2929a.99985.99985 0 0 1 -1.414-1.414z" fill="#003a80"/></g></svg>`;
export const snakeSvg = `<svg enable-background="new 0 0 16 16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g id="flat"><g><g><path d="m8 16h-6.5c-.8 0-1.5-.7-1.5-1.5v-13c0-.8.7-1.5 1.5-1.5h6.5z" fill="#5b5c5f"/></g><g><path d="m14.5 16h-6.5v-16h6.5c.8 0 1.5.7 1.5 1.5v13c0 .8-.7 1.5-1.5 1.5z" fill="#212121"/></g><g><circle cx="11.5" cy="13.5" fill="#d32f2f" r=".5"/></g><g fill="#00e680"><path d="m8 8h-1.5c-.3 0-.5-.2-.5-.5v-1c0-.3.2-.5.5-.5h1.5v-1h-1.5c-.8 0-1.5.7-1.5 1.5v1c0 .8.7 1.5 1.5 1.5h1.5z"/><path d="m8 2h-5.5c-.3 0-.5.2-.5.5s.2.5.5.5h5.5z"/></g><g fill="#009954"><path d="m10.5 8h-2.5v1h2.5c.3 0 .5.2.5.5v2c0 .3.2.5.5.5s.5-.2.5-.5v-2c0-.8-.7-1.5-1.5-1.5z"/><path d="m12.5 2h-4.5v1h4.5c.3 0 .5.2.5.5v1c0 .3-.2.5-.5.5h-4.5v1h4.5c.8 0 1.5-.7 1.5-1.5v-1c0-.8-.7-1.5-1.5-1.5z"/></g></g></g></svg>`;
export const tetrisSvg = `<svg height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="3.25" x2="60.75" y1="32" y2="32"><stop offset="0" stop-color="#5bb4f6"/><stop offset=".61" stop-color="#2191e5"/><stop offset="1" stop-color="#007edb"/></linearGradient><g id="Layer_14" data-name="Layer 14"><rect fill="url(#linear-gradient)" height="57.5" rx="12.75" width="57.5" x="3.25" y="3.25"/><g fill="#fff"><path d="m34.45 41.49h-4.14v-4.15a.76.76 0 0 0 -.75-.75h-9v-4.14a.76.76 0 0 0 -.75-.75h-4.9a.75.75 0 0 0 -.75.75v14.69a.74.74 0 0 0 .75.75h19.54a.75.75 0 0 0 .75-.75v-4.9a.76.76 0 0 0 -.75-.75zm-10.55 4.9h-8.29v-13.19h3.39v9a.74.74 0 0 0 .75.75h4.15zm9.8 0h-8.29v-4.15a.76.76 0 0 0 -.75-.75h-4.15v-3.4h8.3v4.15a.74.74 0 0 0 .75.75h4.14z"/><path d="m29.55 23.4h4.15v4.15a.75.75 0 0 0 .75.75h4.9a.76.76 0 0 0 .75-.75v-14.69a.75.75 0 0 0 -.75-.75h-4.9a.74.74 0 0 0 -.75.75v4.14h-4.15a.74.74 0 0 0 -.75.75v4.89a.74.74 0 0 0 .75.76zm.75-4.89h4.15a.76.76 0 0 0 .75-.75v-4.15h3.4v13.19h-3.4v-4.15a.76.76 0 0 0 -.75-.75h-4.15z"/><path d="m49.14 41.49h-4.14v-9a.76.76 0 0 0 -.75-.75h-4.9a.75.75 0 0 0 -.75.75v14.65a.74.74 0 0 0 .75.75h9.79a.75.75 0 0 0 .75-.75v-4.9a.76.76 0 0 0 -.75-.75zm-.75 4.9h-8.29v-13.19h3.4v9a.74.74 0 0 0 .75.75h4.14z"/></g></g></svg>`;

67
apps/rebreak-native/dev-ios.sh Executable file
View File

@ -0,0 +1,67 @@
#!/bin/bash
# Rebreak Native: Dev-Server (Metro) + iOS Build & Run
# Pendant zu apps/rebreak/dev-ios.sh, aber für React Native + Expo statt Capacitor.
set -e
cd "$(dirname "$0")"
echo "🧠 Rebreak Native iOS Dev"
echo "========================="
# Modi:
# ./dev-ios.sh → öffnet Xcode-Workspace (Default — User baut auf iPhone)
# ./dev-ios.sh --device → physisches iPhone via USB (CLI-Build + Auto-Launch)
# ./dev-ios.sh --simulator → iOS Simulator (schnellster Test-Loop, weniger Auth/Push-Test)
MODE="${1:-xcode}"
# Metro-Port wird NICHT mehr automatisch gekillt — falls du Metro in einem
# anderen Terminal laufen hast, würde das hier die Session zerstören.
# Falls Metro hängt: manuell `lsof -ti:8081 | xargs kill -9` ausführen.
# Cocoapods: läuft beim ersten Run automatisch via expo run:ios.
# Falls "objectVersion 70 not supported" Fehler unter Xcode 26 → CocoaPods updaten:
# sudo gem install cocoapods --pre
#
# Podfile-Fixes werden durch Config-Plugins automatisch reinpatcht:
# - plugins/with-fmt-consteval-fix.js → FMT_USE_CONSTEVAL=0 (Xcode 16 + RN 0.79)
# - plugins/with-rebreak-protection-ios.js → NEFilter Extension Target
# → expo prebuild --clean ist daher SAFE (Plugins regenerieren die Patches).
#
# Für radikalen Cache-Reset: ./clean-ios.sh
# Bei Build-Errors aus dem Nichts: ./clean-ios.sh --build
# 3. Run je nach Mode
case "$MODE" in
--xcode|xcode|"")
# Falls Xcode bereits mit Rebreak.xcodeproj (statt .xcworkspace) offen ist,
# schließe ihn erst. Sonst kriegst du zwei Project-Windows.
osascript -e 'tell application "Xcode" to close every window whose name contains "Rebreak.xcodeproj"' 2>/dev/null || true
echo "🔨 Opening Xcode-Workspace..."
open -a Xcode ios/Rebreak.xcworkspace
echo ""
echo "✅ Xcode offen — In Xcode:"
echo " 1. iPhone via USB anschließen (falls nicht schon)"
echo " 2. Top-Bar: iPhone als Run-Target wählen (links neben Play-Button)"
echo " 3. Cmd+R für Build & Run auf iPhone"
echo ""
echo " Metro: separat starten via 'pnpm expo start --dev-client' falls noch nicht läuft"
;;
--device|device)
echo "📱 Building für physisches iPhone (USB)..."
echo " Erste Mal: Xcode wird geöffnet zum Signing-Setup."
pnpm expo run:ios --device
;;
--simulator|simulator)
echo "📱 Building für iOS Simulator..."
pnpm expo run:ios
;;
*)
echo "Unknown mode: $MODE"
echo "Usage: ./dev-ios.sh [--xcode|--device|--simulator]"
exit 1
;;
esac

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Rebreak Native — Dev auf physischem iPhone (kein Simulator).
#
# Was es macht:
# - Killt alte Metro-Instanzen auf 8081 (saubere Session)
# - Startet Metro mit --host lan damit iPhone via WiFi connecten kann
# - Druckt deine LAN-IP zum manuellen Eintragen falls Bonjour failt
#
# Auf iPhone:
# 1. Mac + iPhone müssen im SELBEN WiFi sein
# 2. App komplett killen (App-Switcher → swipe up)
# 3. App neu öffnen — dev-client sollte Metro automatisch finden
# 4. Falls nicht: dev-launcher → "Enter URL manually" → http://<LAN-IP>:8081
#
# WICHTIG: Im Metro-Terminal NICHT `i` drücken — sonst startet Simulator!
# Nur `r` für Reload.
set -e
cd "$(dirname "$0")"
echo "🧹 Killing old Metro on port 8081..."
lsof -ti:8081 | xargs kill -9 2>/dev/null || true
echo ""
echo "📡 Mac LAN-IP für iPhone:"
ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo " (kein WiFi/Ethernet detected)"
echo ""
echo " Falls dev-client Metro nicht automatisch findet:"
echo " im iPhone-Launcher → 'Enter URL manually' → http://<LAN-IP>:8081"
echo ""
echo "🚀 Starting Metro with --host lan..."
echo " (Drücke 'r' für Reload, NICHT 'i' — sonst startet Simulator!)"
echo ""
exec pnpm expo start --host lan --clear --dev-client

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,54 @@
import { useCallback, useState } from 'react';
import Constants from 'expo-constants';
import { supabase } from '../lib/supabase';
import { protection } from '../lib/protection';
type SyncResult = { ok: boolean; count?: number; plan?: string; error?: string };
/**
* Synct die binary Blocklist (`blocklist.bin`) vom Server in die App-Group.
* Die NEFilter-Extension memory-mapped diese Datei ohne Sync = leere
* Blocklist = nichts wird geblockt.
*
* Triggers:
* - direkt nach activateUrlFilter() success
* - nach Domain-Add/-Submit/-Delete
* - bei App-Resume (in case Server-Updates kamen)
*
* Backend respondet 304 wenn ETag matched kein Re-Download.
*/
export function useBlocklistSync() {
const [syncing, setSyncing] = useState(false);
const [lastResult, setLastResult] = useState<SyncResult | null>(null);
const sync = useCallback(async (): Promise<SyncResult> => {
if (syncing) return { ok: false, error: 'already_syncing' };
setSyncing(true);
try {
const baseURL = Constants.expoConfig?.extra?.apiUrl as string;
const session = (await supabase.auth.getSession()).data.session;
const authToken = session?.access_token;
if (!baseURL || !authToken) {
const result = { ok: false, error: 'missing_baseURL_or_token' };
setLastResult(result);
return result;
}
const res = await protection.syncBlocklist({ baseURL, authToken });
const result = { ok: true, count: res.count, plan: res.plan };
setLastResult(result);
console.log('[blocklist-sync] ok:', res);
return result;
} catch (e: any) {
const result = { ok: false, error: e?.message ?? 'sync_failed' };
setLastResult(result);
console.error('[blocklist-sync] failed:', e);
return result;
} finally {
setSyncing(false);
}
}, [syncing]);
return { sync, syncing, lastResult };
}

View File

@ -0,0 +1,129 @@
import { useEffect } from "react";
import { supabase } from "../lib/supabase";
import type { RealtimeChannel } from "@supabase/supabase-js";
/**
* Realtime-Subscription für DM-Konversation:
* Lauscht auf INSERT in rebreak.direct_messages mit sender_id=eq.{partnerId}.
* Filter: Wir bekommen nur Nachrichten DES Partners (eigene werden lokal optimistisch
* hinzugefügt). callback erhält die rohe Postgres-Row.
*/
export function useDmRealtime(
partnerId: string | undefined,
onInsert: (row: any) => void,
enabled: boolean = true,
) {
useEffect(() => {
if (!enabled || !partnerId) return;
let channel: RealtimeChannel | null = null;
let cancelled = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
async function subscribe() {
const { data } = await supabase.auth.getSession();
if (cancelled || !data.session?.access_token) return;
supabase.realtime.setAuth(data.session.access_token);
channel = supabase
.channel(`dm:${partnerId}:${Date.now()}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "rebreak",
table: "direct_messages",
filter: `sender_id=eq.${partnerId}`,
},
(payload: any) => {
onInsert(payload.new);
},
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
cleanup();
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!cancelled) subscribe();
}, 3000);
}
});
}
function cleanup() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
subscribe();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanup();
};
}, [partnerId, enabled, onInsert]);
}
/**
* Realtime für Gruppen-Chat: lauscht auf INSERT in rebreak.chat_messages mit room_id=eq.{roomId}.
*/
export function useRoomRealtime(
roomId: string | undefined,
myUserId: string | undefined,
onInsert: (row: any) => void,
enabled: boolean = true,
) {
useEffect(() => {
if (!enabled || !roomId) return;
let channel: RealtimeChannel | null = null;
let cancelled = false;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
async function subscribe() {
const { data } = await supabase.auth.getSession();
if (cancelled || !data.session?.access_token) return;
supabase.realtime.setAuth(data.session.access_token);
channel = supabase
.channel(`room:${roomId}:${Date.now()}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "rebreak",
table: "chat_messages",
filter: `room_id=eq.${roomId}`,
},
(payload: any) => {
// Eigene Nachrichten überspringen (lokal optimistisch hinzugefügt)
if (payload.new?.user_id === myUserId) return;
onInsert(payload.new);
},
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
cleanup();
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!cancelled) subscribe();
}, 3000);
}
});
}
function cleanup() {
if (channel) {
supabase.removeChannel(channel);
channel = null;
}
}
subscribe();
return () => {
cancelled = true;
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanup();
};
}, [roomId, myUserId, enabled, onInsert]);
}

Some files were not shown because too many files have changed in this diff Show More