initial commit: rebreak-monorepo (RN app + standalone Nitro backend)
29
.gitignore
vendored
Normal 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
@ -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/
|
||||||
2
apps/rebreak-native/.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
node-linker=hoisted
|
||||||
|
shamefully-hoist=true
|
||||||
112
apps/rebreak-native/README.md
Normal 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)
|
||||||
105
apps/rebreak-native/app.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
217
apps/rebreak-native/app/(app)/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
310
apps/rebreak-native/app/(app)/blocker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
410
apps/rebreak-native/app/(app)/chat.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
24
apps/rebreak-native/app/(app)/coach.tsx
Normal 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' }} />;
|
||||||
|
}
|
||||||
198
apps/rebreak-native/app/(app)/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
235
apps/rebreak-native/app/(app)/mail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/rebreak-native/app/(app)/notifications.tsx
Normal 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';
|
||||||
|
}
|
||||||
12
apps/rebreak-native/app/(auth)/_layout.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
apps/rebreak-native/app/(auth)/confirm-otp.tsx
Normal 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' }}>✉</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
apps/rebreak-native/app/(auth)/confirm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/rebreak-native/app/(auth)/device-limit.tsx
Normal 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' }}>📱</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/rebreak-native/app/(auth)/forgot-password.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
apps/rebreak-native/app/(auth)/signin.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
apps/rebreak-native/app/(auth)/signup.tsx
Normal 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' }}>🛡</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
apps/rebreak-native/app/_layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/rebreak-native/app/auth/callback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
365
apps/rebreak-native/app/dm.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
31
apps/rebreak-native/app/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
976
apps/rebreak-native/app/lyra.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
856
apps/rebreak-native/app/room.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
222
apps/rebreak-native/app/settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1261
apps/rebreak-native/app/urge.tsx
Normal file
BIN
apps/rebreak-native/assets/adaptive-icon-android.png
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
apps/rebreak-native/assets/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
apps/rebreak-native/assets/icon.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
apps/rebreak-native/assets/lyra-avatar.riv
Normal file
BIN
apps/rebreak-native/assets/splash.png
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
apps/rebreak-native/assets/tabs/chatbubble.png
Normal file
|
After Width: | Height: | Size: 437 B |
BIN
apps/rebreak-native/assets/tabs/chatbubble@2x.png
Normal file
|
After Width: | Height: | Size: 833 B |
BIN
apps/rebreak-native/assets/tabs/chatbubble@3x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/rebreak-native/assets/tabs/home.png
Normal file
|
After Width: | Height: | Size: 517 B |
BIN
apps/rebreak-native/assets/tabs/home@2x.png
Normal file
|
After Width: | Height: | Size: 1008 B |
BIN
apps/rebreak-native/assets/tabs/home@3x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/rebreak-native/assets/tabs/mail.png
Normal file
|
After Width: | Height: | Size: 397 B |
BIN
apps/rebreak-native/assets/tabs/mail@2x.png
Normal file
|
After Width: | Height: | Size: 745 B |
BIN
apps/rebreak-native/assets/tabs/mail@3x.png
Normal file
|
After Width: | Height: | Size: 997 B |
BIN
apps/rebreak-native/assets/tabs/shield-checkmark.png
Normal file
|
After Width: | Height: | Size: 489 B |
BIN
apps/rebreak-native/assets/tabs/shield-checkmark@2x.png
Normal file
|
After Width: | Height: | Size: 961 B |
BIN
apps/rebreak-native/assets/tabs/shield-checkmark@3x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/rebreak-native/assets/tabs/sparkles.png
Normal file
|
After Width: | Height: | Size: 517 B |
BIN
apps/rebreak-native/assets/tabs/sparkles@2x.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
apps/rebreak-native/assets/tabs/sparkles@3x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
20
apps/rebreak-native/babel.config.js
Normal 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',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
83
apps/rebreak-native/clean-ios.sh
Executable 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
|
||||||
238
apps/rebreak-native/components/AppHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
apps/rebreak-native/components/BrandSplash.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/rebreak-native/components/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/rebreak-native/components/Card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
apps/rebreak-native/components/ComposeCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
apps/rebreak-native/components/ConfirmAlert.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/rebreak-native/components/EmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
apps/rebreak-native/components/HeroShieldCheck.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/rebreak-native/components/IconButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/rebreak-native/components/NativeTabs.tsx
Normal 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);
|
||||||
321
apps/rebreak-native/components/NotificationsDropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
558
apps/rebreak-native/components/PostCard.tsx
Normal 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 ''; }
|
||||||
|
}
|
||||||
18
apps/rebreak-native/components/PostCardSkeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
503
apps/rebreak-native/components/PostCommentsSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/rebreak-native/components/RiveAvatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/rebreak-native/components/StreakBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
apps/rebreak-native/components/SuccessAlert.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
apps/rebreak-native/components/blocker/AddDomainSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/rebreak-native/components/blocker/CooldownBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
515
apps/rebreak-native/components/blocker/DomainGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/rebreak-native/components/blocker/LayerSwitchCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
apps/rebreak-native/components/blocker/ProtectionCard.tsx
Normal 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);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/rebreak-native/components/blocker/ProtectionLockedCard.tsx
Normal 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);
|
||||||
|
}
|
||||||
442
apps/rebreak-native/components/chat/ChatBubble.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
332
apps/rebreak-native/components/chat/ChatInput.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
282
apps/rebreak-native/components/chat/CreateRoomSheet.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
217
apps/rebreak-native/components/chat/RoomCard.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
605
apps/rebreak-native/components/mail/ConnectMailSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
apps/rebreak-native/components/mail/EditMailAccountSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
391
apps/rebreak-native/components/mail/MailAccountCard.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
apps/rebreak-native/components/mail/MailActivityLog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
apps/rebreak-native/components/mail/MailEmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
apps/rebreak-native/components/mail/MailStatsRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
apps/rebreak-native/components/urge/Breathing.tsx
Normal 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 },
|
||||||
|
});
|
||||||
38
apps/rebreak-native/components/urge/GamePickerDrawer.tsx
Normal 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 },
|
||||||
|
});
|
||||||
53
apps/rebreak-native/components/urge/InlineIndicators.tsx
Normal 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' },
|
||||||
|
});
|
||||||
205
apps/rebreak-native/components/urge/InlineRatingDrawer.tsx
Normal 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' },
|
||||||
|
});
|
||||||
90
apps/rebreak-native/components/urge/MessageRow.tsx
Normal 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' },
|
||||||
|
});
|
||||||
211
apps/rebreak-native/components/urge/ShareSuccessDrawer.tsx
Normal 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' },
|
||||||
|
});
|
||||||
141
apps/rebreak-native/components/urge/SosFeedbackModal.tsx
Normal 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' },
|
||||||
|
});
|
||||||
60
apps/rebreak-native/components/urge/TtsProviderToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
1056
apps/rebreak-native/components/urge/UrgeGames.tsx
Normal file
367
apps/rebreak-native/components/urge/UrgeStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
apps/rebreak-native/components/urge/gameSvgs.ts
Normal 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
@ -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
|
||||||
34
apps/rebreak-native/dev-iphone.sh
Executable 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
|
||||||
3
apps/rebreak-native/global.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
54
apps/rebreak-native/hooks/useBlocklistSync.ts
Normal 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 };
|
||||||
|
}
|
||||||
129
apps/rebreak-native/hooks/useChatRealtime.ts
Normal 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]);
|
||||||
|
}
|
||||||