diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx
index 55802a1..6978f2c 100644
--- a/apps/rebreak-native/app/_layout.tsx
+++ b/apps/rebreak-native/app/_layout.tsx
@@ -106,6 +106,38 @@ function RootLayoutInner() {
animation: 'slide_from_right',
}}
/>
+
+
+
+
>
);
diff --git a/apps/rebreak-native/app/debug.tsx b/apps/rebreak-native/app/debug.tsx
new file mode 100644
index 0000000..63469bf
--- /dev/null
+++ b/apps/rebreak-native/app/debug.tsx
@@ -0,0 +1,162 @@
+import { useEffect } from 'react';
+import { View, Text, ScrollView, Pressable } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../lib/theme';
+
+export default function DebugScreen() {
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!__DEV__) {
+ router.replace('/');
+ }
+ }, [router]);
+
+ if (!__DEV__) {
+ return ;
+ }
+
+ return (
+
+
+ router.back()}
+ hitSlop={8}
+ style={({ pressed }) => ({
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ opacity: pressed ? 0.6 : 1,
+ })}
+ >
+
+
+
+ Debug
+
+
+
+
+
+
+
+
+ Dev only
+
+
+ Diese Page ist nur in __DEV__ verfügbar. Production-Builds redirecten auf /.
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DebugStub({
+ title,
+ subtitle,
+ icon,
+}: {
+ title: string;
+ subtitle: string;
+ icon: React.ComponentProps['name'];
+}) {
+ return (
+
+
+
+
+
+ {title}
+
+ {subtitle}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/app/games.tsx b/apps/rebreak-native/app/games.tsx
new file mode 100644
index 0000000..7e4b450
--- /dev/null
+++ b/apps/rebreak-native/app/games.tsx
@@ -0,0 +1,240 @@
+import { useEffect, useState } from 'react';
+import { View, Text, Pressable, ScrollView } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useRouter } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { useTranslation } from 'react-i18next';
+import {
+ GAME_META,
+ type GameType,
+ MemoryGame,
+ TicTacToeGame,
+ SnakeGame,
+ TetrisGame,
+} from '../components/urge/UrgeGames';
+import { GameCard } from '../components/games/GameCard';
+import { colors } from '../lib/theme';
+import { apiFetch } from '../lib/api';
+
+type GameStat = { avgStars: number; count: number };
+type GameStats = Record;
+
+const EMPTY_STATS: GameStats = {
+ memory: { avgStars: 0, count: 0 },
+ tictactoe: { avgStars: 0, count: 0 },
+ snake: { avgStars: 0, count: 0 },
+ tetris: { avgStars: 0, count: 0 },
+};
+
+type LastScore = { game: GameType; score: number } | null;
+
+export default function GamesScreen() {
+ const router = useRouter();
+ const { t } = useTranslation();
+ const [active, setActive] = useState(null);
+ const [lastScore, setLastScore] = useState(null);
+ const [gameStats, setGameStats] = useState(EMPTY_STATS);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ // Backend response: { ratings, stats: [{ gameName, avgStars, count }] }
+ const data = await apiFetch<{
+ stats: Array<{ gameName: string; avgStars: number; count: number }>;
+ }>('/api/games/ratings');
+ if (cancelled) return;
+ const next: GameStats = { ...EMPTY_STATS };
+ for (const s of data.stats ?? []) {
+ const key = s.gameName.toLowerCase() as GameType;
+ if (key in next) {
+ next[key] = { avgStars: s.avgStars ?? 0, count: s.count ?? 0 };
+ }
+ }
+ setGameStats(next);
+ } catch {
+ // Silent fail — UI shows 0 stars/count, kein Crash
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ function exit(score?: number) {
+ if (typeof score === 'number' && active) {
+ setLastScore({ game: active, score });
+ }
+ setActive(null);
+ }
+
+ if (active) {
+ return (
+
+
+ exit()}
+ hitSlop={10}
+ style={({ pressed }) => ({
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ paddingHorizontal: 6,
+ paddingVertical: 6,
+ opacity: pressed ? 0.6 : 1,
+ })}
+ >
+
+
+ {t('games.back_to_picker')}
+
+
+
+ {t(GAME_META.find((g) => g.id === active)!.titleKey)}
+
+
+
+
+
+ {active === 'memory' ? (
+ exit(s)} onAbandon={() => exit()} />
+ ) : null}
+ {active === 'tictactoe' ? (
+ exit(s)} onAbandon={() => exit()} />
+ ) : null}
+ {active === 'snake' ? (
+ exit(s)} onAbandon={() => exit()} />
+ ) : null}
+ {active === 'tetris' ? (
+ exit(s)} onAbandon={() => exit()} />
+ ) : null}
+
+
+ );
+ }
+
+ return (
+
+
+ router.back()}
+ hitSlop={8}
+ style={({ pressed }) => ({
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ opacity: pressed ? 0.6 : 1,
+ })}
+ >
+
+
+
+ {t('games.title')}
+
+
+
+
+
+ {t('games.subtitle')}
+
+
+
+ {GAME_META.map((game) => {
+ const stat = gameStats[game.id] ?? { avgStars: 0, count: 0 };
+ const recent = lastScore?.game === game.id ? lastScore.score : null;
+ return (
+
+ setActive(id)}
+ />
+ {recent !== null ? (
+
+
+ {t('games.last_score', { score: recent })}
+
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ {t('games.skeleton_footer')}
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/app/profile/[userId].tsx b/apps/rebreak-native/app/profile/[userId].tsx
new file mode 100644
index 0000000..d99af55
--- /dev/null
+++ b/apps/rebreak-native/app/profile/[userId].tsx
@@ -0,0 +1,315 @@
+import { useState } from 'react';
+import { View, Text, ScrollView, Pressable, Image } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+import { resolveAvatar } from '../../lib/resolveAvatar';
+import type { Plan } from '../../hooks/useUserPlan';
+
+const planLabel: Record = {
+ free: 'Free',
+ pro: 'Pro',
+ legend: 'Legend',
+};
+
+const planColors: Record = {
+ free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' },
+ pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' },
+ legend: { bg: '#fef9c3', text: '#854d0e', border: '#fde68a' },
+};
+
+// TODO: GET /api/social/profile/[userId] — extend response um approvedDomainsCount.
+// Strikt anonym: nur nickname, avatar, plan, memberSince, postsCount, followersCount,
+// approvedDomainsCount, isFollowing. NIEMALS email, demographics, cooldowns, sos-insights.
+type ForeignProfile = {
+ id: string;
+ nickname: string;
+ avatar: string | null;
+ plan: Plan;
+ memberSince: string;
+ postsCount: number;
+ followersCount: number;
+ approvedDomainsCount: number;
+ isFollowing: boolean;
+};
+
+const DUMMY_FOREIGN: ForeignProfile = {
+ id: 'foreign-user-id',
+ nickname: 'Jonas_42',
+ avatar: 'wolf',
+ plan: 'pro',
+ memberSince: 'April 2026',
+ postsCount: 12,
+ followersCount: 47,
+ approvedDomainsCount: 8,
+ isFollowing: false,
+};
+
+type StatProps = {
+ value: string;
+ label: string;
+};
+
+function ForeignStat({ value, label }: StatProps) {
+ return (
+
+
+ {value}
+
+
+ {label}
+
+
+ );
+}
+
+export default function ForeignProfileScreen() {
+ const insets = useSafeAreaInsets();
+ const router = useRouter();
+ const { userId } = useLocalSearchParams<{ userId: string }>();
+ const [imageFailed, setImageFailed] = useState(false);
+ const [isFollowing, setIsFollowing] = useState(DUMMY_FOREIGN.isFollowing);
+
+ // TODO: useQuery → apiFetch(`/api/social/profile/${userId}`)
+ const profile = DUMMY_FOREIGN;
+ void userId;
+
+ const avatarUrl = resolveAvatar(profile.avatar, profile.nickname);
+ const initials = profile.nickname.slice(0, 2).toUpperCase();
+ const showImage = !!profile.avatar && !imageFailed;
+ const planStyle = planColors[profile.plan];
+
+ return (
+
+
+
+ router.back()}
+ hitSlop={8}
+ style={({ pressed }) => ({
+ opacity: pressed ? 0.5 : 1,
+ padding: 8,
+ })}
+ >
+
+
+
+ Profil
+
+
+
+
+
+
+
+
+ {showImage ? (
+ setImageFailed(true)}
+ style={{ width: 92, height: 92, borderRadius: 46 }}
+ />
+ ) : (
+
+ {initials}
+
+ )}
+
+
+
+ {profile.nickname}
+
+
+
+
+
+ {planLabel[profile.plan].toUpperCase()}
+
+
+
+ Mitglied seit {profile.memberSince}
+
+
+
+
+ {
+ // TODO: POST /api/social/follow/[userId] resp. DELETE bei unfollow
+ setIsFollowing((v) => !v);
+ }}
+ style={({ pressed }) => ({
+ flex: 1,
+ opacity: pressed ? 0.7 : 1,
+ paddingVertical: 11,
+ borderRadius: 12,
+ backgroundColor: isFollowing ? '#f5f5f5' : colors.brandOrange,
+ borderWidth: 1,
+ borderColor: isFollowing ? '#e5e5e5' : colors.brandOrange,
+ alignItems: 'center',
+ })}
+ >
+
+ {isFollowing ? 'Folge ich' : 'Folgen'}
+
+
+ {
+ // TODO: navigate to DM with this userId
+ router.push(`/dm`);
+ }}
+ style={({ pressed }) => ({
+ flex: 1,
+ opacity: pressed ? 0.7 : 1,
+ paddingVertical: 11,
+ borderRadius: 12,
+ backgroundColor: '#ffffff',
+ borderWidth: 1,
+ borderColor: '#e5e5e5',
+ alignItems: 'center',
+ })}
+ >
+
+ Nachricht
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* TODO: GET /api/community/posts?userId=... — letzte 5 Posts */}
+
+
+ LETZTE POSTS
+
+
+
+ Posts-Liste folgt in Phase C
+
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/app/profile/index.tsx b/apps/rebreak-native/app/profile/index.tsx
new file mode 100644
index 0000000..0ed5075
--- /dev/null
+++ b/apps/rebreak-native/app/profile/index.tsx
@@ -0,0 +1,272 @@
+import { useRef, useState } from 'react';
+import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { AppHeader } from '../../components/AppHeader';
+import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
+import { StatsBar } from '../../components/profile/StatsBar';
+import { ApprovedDomainsList, type ApprovedDomain } from '../../components/profile/ApprovedDomainsList';
+import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
+import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
+import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
+import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner';
+import { colors } from '../../lib/theme';
+import type { Plan } from '../../hooks/useUserPlan';
+import { useMe } from '../../hooks/useMe';
+import { useAuthStore } from '../../stores/auth';
+
+// TODO Phase C: GET /api/profile/me — aggregate endpoint (profile + stats + streak +
+// recentCooldowns + demographics + sosInsights). Until backend live:
+// - Core User-Felder (nickname/email/avatar/plan) kommen aus useMe-Hook (live)
+// - Stats/Streak/Cooldowns/Demographics bleiben dummy
+const DUMMY_PROFILE_FALLBACK = {
+ memberSince: 'April 2026', // TODO Phase C: aus profile.created_at
+ provider: 'email' as AuthProvider, // TODO Phase C: aus user.app_metadata.provider
+};
+
+const DUMMY_STATS = {
+ postsCount: 12,
+ followersCount: 47,
+ // Approved Domains = Community-Beitrag (KEIN Plan-Slot, kein Cap).
+ // Source: domain_submissions WHERE userId=me AND status='approved'.
+ // TODO Phase C: GET /api/profile/me/approved-domains (Endpoint existiert noch NICHT
+ // — gefunden wurden nur admin-side aggregate counts in
+ // backend/server/api/admin/stats.get.ts und backend/server/api/blocklist/stats.get.ts).
+ // Neuer Endpoint nötig: GET /api/profile/me/approved-domains → { count, list[] }.
+ approvedDomainsCount: 5,
+};
+
+const DUMMY_STREAK = {
+ currentDays: 23,
+ longestDays: 41,
+ startDate: '14. April 2026',
+};
+
+// TODO: GET /api/profile/me/cooldown-history?cursor=...
+const DUMMY_COOLDOWNS: CooldownEntry[] = [
+ {
+ id: 'c1',
+ startedAt: '06.05.',
+ durationLabel: '24h',
+ status: 'active',
+ reason: null,
+ },
+ {
+ id: 'c2',
+ startedAt: '02.05.',
+ durationLabel: '4h',
+ status: 'cancelled',
+ reason: null,
+ },
+ {
+ id: 'c3',
+ startedAt: '18.04.',
+ durationLabel: '16h',
+ status: 'resolved',
+ reason: 'Stress nach Arbeit',
+ },
+];
+
+// TODO: GET /api/profile/me/approved-domains
+const DUMMY_APPROVED_DOMAINS: ApprovedDomain[] = [
+ { domain: 'tipico.de', approvedAt: '12.04.' },
+ { domain: 'bwin.com', approvedAt: '15.04.' },
+ { domain: 'merkur24.com', approvedAt: '20.04.' },
+ { domain: 'sunmaker.com', approvedAt: '28.04.' },
+ { domain: 'lottoland.com', approvedAt: '02.05.' },
+];
+
+// TODO: GET /api/profile/me/sos-insights
+const DUMMY_HELPED_BY: HelpedByEntry[] = [
+ { key: 'breathing', label: 'Atemübung', count: 3 },
+ { key: 'game', label: 'Spiel', count: 1 },
+ { key: 'talk', label: 'Reden mit Lyra', count: 1 },
+];
+
+// TODO: GET /api/profile/me/demographics — gehört zur me-aggregat-response
+const DUMMY_DEMOGRAPHICS: Demographics = {
+ birthYear: 1989,
+ gender: 'diverse',
+ maritalStatus: null,
+ profession: null,
+ bundesland: 'BY',
+ city: null,
+};
+
+function isDemographicsComplete(d: Demographics): boolean {
+ return (
+ d.birthYear !== null &&
+ !!d.gender &&
+ !!d.maritalStatus &&
+ !!d.profession &&
+ !!d.bundesland &&
+ !!d.city
+ );
+}
+
+export default function ProfileScreen() {
+ const insets = useSafeAreaInsets();
+ const [bannerDismissed, setBannerDismissed] = useState(false);
+ const [demographics, setDemographics] = useState(DUMMY_DEMOGRAPHICS);
+ const { me } = useMe();
+ const { user } = useAuthStore();
+
+ const scrollViewRef = useRef(null);
+ const demographicsAnchorRef = useRef(null);
+
+ // Live-Daten aus DB (für Avatar / Nickname / Plan / Email).
+ // Provider-Detection: user.app_metadata.provider vom Supabase-OAuth-Flow.
+ const provider: AuthProvider =
+ ((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
+ const profile = {
+ nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
+ email: user?.email ?? '',
+ avatar: me?.avatar ?? null,
+ plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
+ memberSince: DUMMY_PROFILE_FALLBACK.memberSince,
+ provider,
+ };
+
+ const showDigaBanner = DUMMY_STREAK.currentDays >= 30 && !bannerDismissed;
+ const demoComplete = isDemographicsComplete(demographics);
+
+ function scrollToDemographics() {
+ const node = demographicsAnchorRef.current;
+ const scroll = scrollViewRef.current;
+ if (!node || !scroll) return;
+ const handle = findNodeHandle(node);
+ const scrollHandle = findNodeHandle(scroll);
+ if (!handle || !scrollHandle) return;
+ UIManager.measureLayout(
+ handle,
+ scrollHandle,
+ () => {
+ // measure failure — silent
+ },
+ (_x, y) => {
+ scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
+ },
+ );
+ }
+
+ return (
+
+
+
+ {
+ // TODO Phase C: AvatarPickerSheet (preset-grid + custom-upload via expo-image-picker)
+ Alert.alert(
+ 'Avatar bearbeiten',
+ 'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.',
+ );
+ }}
+ onEditNickname={() => {
+ // TODO Phase C: NicknameEditSheet → PATCH /api/auth/me
+ Alert.alert(
+ 'Nickname bearbeiten',
+ 'Inline-Edit + Save kommt in der nächsten Iteration.',
+ );
+ }}
+ />
+
+
+
+
+ {
+ // TODO: Phase C — navigate to user's own posts list
+ }}
+ onFollowersPress={() => {
+ // TODO: Phase C — open FollowersSheet
+ }}
+ onApprovedDomainsPress={() => {
+ // TODO: Phase C — scroll to ApprovedDomainsList + auto-expand
+ }}
+ />
+
+
+
+
+ {showDigaBanner ? (
+ {
+ // TODO: AsyncStorage persist `diga_banner_dismissed_at`
+ setBannerDismissed(true);
+ }}
+ onContribute={() => {
+ setBannerDismissed(true);
+ scrollToDemographics();
+ }}
+ />
+ ) : null}
+
+
+
+
+
+ {/* Anchor: Hint-Tap im Header scrollt hierhin */}
+
+ {
+ // TODO Phase C: PATCH /api/profile/me/demographics — Body: next
+ // Endpoint: profile.demographics_consent_at = NOW() bei erstem Save (DSGVO-Audit-Trail).
+ // Plan-Trial-Trigger: wenn alle 6 Felder gefüllt + plan='free' → server setzt
+ // pro_trial_started_at + pro_trial_expires_at + pro_trial_source='demographics_complete'.
+ setDemographics(next);
+ }}
+ onRevokeConsent={() => {
+ // TODO: Phase C — DELETE /api/profile/me/demographics, confirm-alert first
+ }}
+ />
+
+
+
+
+ Profil-Skeleton (dummy data) — Backend wired in Phase C
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/app/settings.tsx b/apps/rebreak-native/app/settings.tsx
index 6dd9642..c37be78 100644
--- a/apps/rebreak-native/app/settings.tsx
+++ b/apps/rebreak-native/app/settings.tsx
@@ -1,221 +1,310 @@
-import { ScrollView, View, Text, Pressable, Switch } from 'react-native';
+import { ScrollView, View, Text, Pressable, Platform } 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;
+type SectionRow = {
icon: React.ComponentProps['name'];
iconColor: string;
- onPress?: () => void;
- right?: React.ReactNode;
+ label: string;
+ sublabel: string;
+};
+
+type Section = {
+ key: string;
+ title: string;
+ rows: SectionRow[];
};
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();
+ const sections: Section[] = [
+ {
+ key: 'profile',
+ title: t('settings.section_profile'),
+ rows: [
+ {
+ icon: 'person-outline',
+ iconColor: '#6366f1',
+ label: t('settings.profile_edit'),
+ sublabel: t('settings.profile_edit_desc'),
+ },
+ {
+ icon: 'image-outline',
+ iconColor: '#6366f1',
+ label: t('settings.profile_avatar'),
+ sublabel: t('settings.profile_avatar_desc'),
+ },
+ ],
+ },
+ {
+ key: 'theme',
+ title: t('settings.section_theme'),
+ rows: [
+ {
+ icon: 'color-palette-outline',
+ iconColor: '#a78bfa',
+ label: t('settings.theme'),
+ sublabel: t('settings.theme_desc'),
+ },
+ {
+ icon: 'language-outline',
+ iconColor: '#a78bfa',
+ label: t('settings.language'),
+ sublabel: t('settings.language_desc'),
+ },
+ ],
+ },
+ {
+ key: 'notifications',
+ title: t('settings.section_notifications'),
+ rows: [
+ {
+ icon: 'notifications-outline',
+ iconColor: '#2563eb',
+ label: t('settings.notifications_push'),
+ sublabel: t('settings.notifications_push_desc'),
+ },
+ {
+ icon: 'flame-outline',
+ iconColor: '#f97316',
+ label: t('settings.notifications_streak'),
+ sublabel: t('settings.notifications_streak_desc'),
+ },
+ ],
+ },
+ {
+ key: 'devices',
+ title: t('settings.section_devices'),
+ rows: [
+ {
+ icon: 'phone-portrait-outline',
+ iconColor: '#16a34a',
+ label: t('settings.devices'),
+ sublabel: t('settings.devices_desc'),
+ },
+ {
+ icon: 'star-outline',
+ iconColor: colors.brandOrange,
+ label: t('settings.subscription'),
+ sublabel: t('settings.subscription_desc'),
+ },
+ ],
+ },
+ {
+ key: 'lyra',
+ title: t('settings.section_lyra'),
+ rows: [
+ {
+ icon: 'mic-outline',
+ iconColor: '#ec4899',
+ label: t('settings.lyra_voice'),
+ sublabel: t('settings.lyra_voice_desc'),
+ },
+ ],
+ },
+ ];
- async function handleSignOut() {
- await signOut();
- router.replace('/');
+ if (__DEV__) {
+ sections.push({
+ key: 'debug',
+ title: t('settings.section_debug'),
+ rows: [
+ {
+ icon: 'bug-outline',
+ iconColor: '#737373',
+ label: t('settings.debug_llm'),
+ sublabel: t('settings.debug_llm_desc'),
+ },
+ {
+ icon: 'volume-high-outline',
+ iconColor: '#737373',
+ label: t('settings.debug_tts'),
+ sublabel: t('settings.debug_tts_desc'),
+ },
+ ],
+ });
}
- 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: (
-
- ),
- },
- {
- label: t('settings.streak_reminders'),
- icon: 'flame-outline',
- iconColor: '#f97316',
- right: (
-
- ),
- },
- {
- label: t('settings.language'),
- sublabel: t('settings.language_current'),
- icon: 'language-outline',
- iconColor: '#a78bfa',
- onPress: () => {},
- },
- ];
-
return (
-
-
+
+
router.replace('/(app)' as never)}
+ onPress={() => router.back()}
hitSlop={8}
- className="w-10 h-10 items-center justify-center"
- style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
+ style={({ pressed }) => ({
+ width: 40,
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ opacity: pressed ? 0.6 : 1,
+ })}
>
- {t('settings.title')}
+
+ {t('settings.title')}
+
- {/* Account Card */}
-
-
-
- {initials}
-
-
-
- {email}
-
-
-
- {t('settings.plan_free')}
-
-
-
-
-
-
-
-
- {/* Account Section */}
-
- {t('settings.account_section')}
-
-
- {accountRows.map((row, i) => (
- ({ opacity: pressed ? 0.7 : 1 })}
+
+
+
+
+ {t('settings.coming_soon_title')}
+
+
-
-
-
-
- {row.label}
- {row.sublabel ? (
- {row.sublabel}
- ) : null}
-
- {row.right ?? (
-
- )}
-
- ))}
-
+ {t('settings.coming_soon_desc')}
+
+
+
- {/* Preferences Section */}
-
- {t('settings.prefs_section')}
-
-
- {prefRows.map((row, i) => (
+ {sections.map((section) => (
+
+
+ {section.title}
+
-
-
-
-
- {row.label}
- {row.sublabel ? (
- {row.sublabel}
- ) : null}
-
- {row.right ?? (
-
-
-
- )}
+ {section.rows.map((row, i) => (
+
+
+
+
+
+
+ {row.label}
+
+
+ {row.sublabel}
+
+
+
+ {t('settings.soon_badge')}
+
+
+ ))}
- ))}
-
+
+ ))}
- {/* Danger Zone */}
-
- {t('settings.danger_section')}
+
+ {t('settings.skeleton_footer')}
+
+
+ {Platform.OS}
-
-
-
- {t('settings.delete_desc')}
-
-
-
-
);
diff --git a/apps/rebreak-native/app/urge.tsx b/apps/rebreak-native/app/urge.tsx
index ad011bf..1fc0c98 100644
--- a/apps/rebreak-native/app/urge.tsx
+++ b/apps/rebreak-native/app/urge.tsx
@@ -350,7 +350,7 @@ export default function SOSScreen() {
// Latenz-Benchmark — eine Session pro sendToLyra-Call. Marker werden in
// stream/queue über onMetric gesammelt, gedruckt im onIdle (oder als
// Fallback im finally bei Errors / sound-off).
- const bench = new BenchSession({ provider: currentProvider(), label: 'send' });
+ const bench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'send' });
try {
const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content }));
@@ -624,7 +624,7 @@ export default function SOSScreen() {
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
// Latenz-Benchmark fürs Greeting — gleiches Pattern wie sendToLyra.
- const greetingBench = new BenchSession({ provider: currentProvider(), label: 'greeting' });
+ const greetingBench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'greeting' });
// Hybrid-TTS-Queue, gleiches Pattern wie sendToLyra
const ttsQueue = soundEnabledRef.current
diff --git a/apps/rebreak-native/components/AppHeader.tsx b/apps/rebreak-native/components/AppHeader.tsx
index 11e21f0..f881b47 100644
--- a/apps/rebreak-native/components/AppHeader.tsx
+++ b/apps/rebreak-native/components/AppHeader.tsx
@@ -1,28 +1,23 @@
import { useState } from 'react';
-import { View, Text, Pressable, Modal, Image } from 'react-native';
+import { View, Text, Pressable, 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';
+import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
type Props = {
notifCount?: number;
+ showBack?: boolean;
+ title?: string;
};
-type MenuItem = {
- icon: React.ComponentProps['name'];
- label: string;
- color?: string;
- action: () => void;
-};
-
-export function AppHeader({ notifCount }: Props = {}) {
+export function AppHeader({ notifCount, showBack, title }: Props = {}) {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
@@ -30,13 +25,12 @@ export function AppHeader({ notifCount }: Props = {}) {
const { me } = useMe();
const storeUnread = useNotificationStore((s) => s.unread);
const badge = notifCount ?? storeUnread;
- const [dropdownOpen, setDropdownOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
+ const [menuOpen, setMenuOpen] = 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() || '?');
@@ -45,36 +39,10 @@ export function AppHeader({ notifCount }: Props = {}) {
// 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 (
@@ -83,15 +51,27 @@ export function AppHeader({ notifCount }: Props = {}) {
style={{ paddingTop: insets.top }}
>
-
- {t('appHeader.appName')}
-
+
+ {showBack ? (
+ router.back()}
+ hitSlop={10}
+ className="w-9 h-9 rounded-full items-center justify-center"
+ style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginLeft: -8 })}
+ accessibilityLabel="Zurück"
+ >
+
+
+ ) : null}
+
+ {title ?? t('appHeader.appName')}
+
+
-
- {/* Notifications dropdown trigger */}
+
setNotifOpen(true)}
- className="w-9 h-9 rounded-full bg-white items-center justify-center"
+ className="w-9 h-9 rounded-full bg-white items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
@@ -104,9 +84,9 @@ export function AppHeader({ notifCount }: Props = {}) {
)}
- {/* Profil-Avatar — tap → dropdown */}
+ {/* Avatar = Trigger für Dropdown-Menu (kein separates 3-Punkte-Icon) */}
setDropdownOpen(true)}
+ onPress={() => setMenuOpen(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 })}
>
@@ -120,114 +100,15 @@ export function AppHeader({ notifCount }: Props = {}) {
{initials}
)}
+
+ setMenuOpen(false)}
+ topOffset={headerHeight + 6}
+ />
- {/* Dropdown modal */}
- setDropdownOpen(false)}
- >
- setDropdownOpen(false)}
- style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
- >
- 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 */}
- closeAndNavigate('/urge' as RelativePathString)}>
-
-
-
-
-
-
- {t('appHeader.sosLabel')}
-
-
- {t('appHeader.sosSubtitle')}
-
-
-
-
-
-
-
-
- {menuItems.map((item) => (
-
-
-
-
- {item.label}
-
-
-
- ))}
-
-
-
-
-
-
-
- {t('appHeader.signOut')}
-
-
-
-
-
-
-
setNotifOpen(false)}
diff --git a/apps/rebreak-native/components/ComposeCard.tsx b/apps/rebreak-native/components/ComposeCard.tsx
index 9522ac4..c31b651 100644
--- a/apps/rebreak-native/components/ComposeCard.tsx
+++ b/apps/rebreak-native/components/ComposeCard.tsx
@@ -126,9 +126,13 @@ export function ComposeCard({ onPosted }: Props) {
style={{ height: 160 }}
resizeMode="cover"
/>
+ {/* HitSlop +9pt rundum → 28pt visual + 18pt slop ≈ 46pt effektive Tap-Area (HIG ≥44pt). */}
setImageUri(null)}
+ hitSlop={{ top: 9, bottom: 9, left: 9, right: 9 }}
+ android_ripple={{ color: 'rgba(255,255,255,0.18)', borderless: true, radius: 22 }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 items-center justify-center"
+ style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
@@ -139,9 +143,12 @@ export function ComposeCard({ onPosted }: Props) {
{showActions && (
+ {/* Image-Picker: visuell klein (icon 18pt + label), aber hitSlop +12 → effektive Tap-Area ~46pt (HIG-Min 44pt). */}
({ opacity: pressed ? 0.6 : 1 })}
>
@@ -149,12 +156,19 @@ export function ComposeCard({ onPosted }: Props) {
-
+ {/* Cancel-Label: hitSlop sichert ≥44pt Tap-Area trotz nackter Text-Höhe. */}
+ ({ opacity: pressed ? 0.5 : 1 })}
+ >
{t('common.cancel')}
+ {/* Share-Pill: visuell h-8 (32pt) bleibt erhalten — hitSlop +6 vertikal hebt Tap-Area auf 44pt. */}
({
opacity: pressed || !content.trim() || posting ? 0.5 : 1,
diff --git a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx
index 0390065..adce30b 100644
--- a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx
+++ b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx
@@ -309,9 +309,10 @@ export function ProtectionDetailsSheet({
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
-
+
({
+ alignSelf: 'stretch',
marginTop: 4,
paddingVertical: 14,
paddingHorizontal: 16,
@@ -657,6 +659,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
return (
setOpen((v) => !v)}
style={({ pressed }) => ({
+ alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
diff --git a/apps/rebreak-native/components/games/GameCard.tsx b/apps/rebreak-native/components/games/GameCard.tsx
new file mode 100644
index 0000000..f26504d
--- /dev/null
+++ b/apps/rebreak-native/components/games/GameCard.tsx
@@ -0,0 +1,77 @@
+// RN-Port der Vue-Card aus apps/rebreak/app/components/urge/UrgeGamePicker.vue
+// 2x2-Grid-Kachel mit SVG-Icon (56x56), Titel, descKey und Star-Rating.
+import { View, Text, Pressable } from 'react-native';
+import { SvgXml } from 'react-native-svg';
+import { useTranslation } from 'react-i18next';
+import { GameRatingStars } from './GameRatingStars';
+import type { GameType } from '../urge/UrgeGames';
+
+export interface GameCardProps {
+ id: GameType;
+ svg: string;
+ titleKey: string;
+ descKey: string;
+ avgStars: number;
+ count: number;
+ onPress: (id: GameType) => void;
+}
+
+export function GameCard({
+ id,
+ svg,
+ titleKey,
+ descKey,
+ avgStars,
+ count,
+ onPress,
+}: GameCardProps) {
+ const { t } = useTranslation();
+ return (
+ onPress(id)}
+ style={({ pressed }) => ({
+ width: '100%',
+ borderRadius: 18,
+ borderWidth: 1,
+ borderColor: '#e5e7eb',
+ backgroundColor: pressed ? '#f0f9ff' : '#fafafa',
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 18,
+ paddingHorizontal: 12,
+ gap: 12,
+ transform: [{ scale: pressed ? 0.97 : 1 }],
+ })}
+ >
+
+
+
+ {t(titleKey)}
+
+
+ {t(descKey)}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/games/GameRatingStars.tsx b/apps/rebreak-native/components/games/GameRatingStars.tsx
new file mode 100644
index 0000000..73e08ff
--- /dev/null
+++ b/apps/rebreak-native/components/games/GameRatingStars.tsx
@@ -0,0 +1,34 @@
+// RN-Port von apps/rebreak/app/components/sos/GameRatingStars.vue
+import { View, Text } from 'react-native';
+import { StarRating } from './StarRating';
+
+export interface GameRatingStarsProps {
+ avg: number;
+ count: number;
+}
+
+export function GameRatingStars({ avg, count }: GameRatingStarsProps) {
+ return (
+
+
+ {count > 0 ? (
+
+ ({count})
+
+ ) : null}
+
+ );
+}
diff --git a/apps/rebreak-native/components/games/StarRating.tsx b/apps/rebreak-native/components/games/StarRating.tsx
new file mode 100644
index 0000000..271f761
--- /dev/null
+++ b/apps/rebreak-native/components/games/StarRating.tsx
@@ -0,0 +1,106 @@
+// RN-Port von apps/rebreak/app/components/StarRating.vue
+// Unterstützt fractional values (z.B. 3.7) via width-clipping.
+import { View, Pressable } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useState } from 'react';
+
+export type StarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+
+const sizeMap: Record = {
+ xs: 14,
+ sm: 18,
+ md: 20,
+ lg: 28,
+ xl: 40,
+};
+
+export interface StarRatingProps {
+ value?: number;
+ max?: number;
+ size?: StarSize;
+ interactive?: boolean;
+ filledColor?: string;
+ emptyColor?: string;
+ onChange?: (value: number) => void;
+}
+
+export function StarRating({
+ value = 0,
+ max = 5,
+ size = 'md',
+ interactive = false,
+ filledColor = '#facc15',
+ emptyColor = '#e5e7eb',
+ onChange,
+}: StarRatingProps) {
+ const [hover, setHover] = useState(0);
+ const px = sizeMap[size];
+ const display = interactive ? hover || value : value;
+
+ const stars = [];
+ for (let i = 1; i <= max; i++) {
+ const filledRatio = Math.min(Math.max(display - (i - 1), 0), 1);
+ const filledWidth = filledRatio * px;
+
+ const star = (
+
+ {/* Empty star (background) */}
+
+ {/* Filled star clipped to filledWidth */}
+ {filledRatio > 0 ? (
+
+
+
+ ) : null}
+
+ );
+
+ if (interactive) {
+ stars.push(
+ onChange?.(i)}
+ onHoverIn={() => setHover(i)}
+ onHoverOut={() => setHover(0)}
+ hitSlop={4}
+ >
+ {star}
+
+ );
+ } else {
+ stars.push(star);
+ }
+ }
+
+ return (
+
+ {stars}
+
+ );
+}
diff --git a/apps/rebreak-native/components/header/HeaderDropdownMenu.tsx b/apps/rebreak-native/components/header/HeaderDropdownMenu.tsx
new file mode 100644
index 0000000..4a7adab
--- /dev/null
+++ b/apps/rebreak-native/components/header/HeaderDropdownMenu.tsx
@@ -0,0 +1,244 @@
+import { View, Text, Pressable, Modal } from 'react-native';
+import { useRouter, type RelativePathString } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+import { useTranslation } from 'react-i18next';
+import { useAuthStore } from '../../stores/auth';
+
+// Controlled-Modal-Pattern. Trigger ist NICHT in dieser Komponente — der
+// Avatar im AppHeader öffnet das Modal via `visible`-Prop (User-Anweisung
+// 2026-05-07: kein separates 3-Punkte-Icon).
+//
+// Card-Style mit:
+// - SOS prominent oben (nur Wort "SOS" rot, Tagline neutral; ernste Sache,
+// nicht mit Gaming/Profile in eine Liste werfen)
+// - Profile · Settings · Games · [Debug DEV] in der Mitte
+// - Abmelden unten, neutral (nicht rot — Recovery-tonal, kein Alarm)
+
+type ItemKey = 'profile' | 'settings' | 'games' | 'debug';
+
+type Item = {
+ key: ItemKey;
+ label: string;
+ icon: React.ComponentProps['name'];
+ onSelect: () => void | Promise;
+};
+
+type Props = {
+ visible: boolean;
+ onClose: () => void;
+ topOffset?: number;
+};
+
+export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props) {
+ const router = useRouter();
+ const { t } = useTranslation();
+ const { signOut } = useAuthStore();
+
+ function nav(path: RelativePathString) {
+ onClose();
+ router.push(path);
+ }
+
+ async function handleLogout() {
+ onClose();
+ await signOut();
+ router.replace('/' as RelativePathString);
+ }
+
+ const items: Item[] = [
+ {
+ key: 'profile',
+ label: t('headerMenu.profile'),
+ icon: 'person-outline',
+ onSelect: () => nav('/profile' as RelativePathString),
+ },
+ {
+ key: 'settings',
+ label: t('headerMenu.settings'),
+ icon: 'settings-outline',
+ onSelect: () => nav('/settings' as RelativePathString),
+ },
+ {
+ key: 'games',
+ label: t('headerMenu.games'),
+ icon: 'game-controller-outline',
+ onSelect: () => nav('/games' as RelativePathString),
+ },
+ ];
+
+ if (__DEV__) {
+ items.push({
+ key: 'debug',
+ label: t('headerMenu.debug'),
+ icon: 'bug-outline',
+ onSelect: () => nav('/debug' as RelativePathString),
+ });
+ }
+
+ return (
+
+
+ true}
+ style={{
+ position: 'absolute',
+ top: topOffset,
+ right: 12,
+ backgroundColor: '#ffffff',
+ borderRadius: 18,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 8 },
+ shadowOpacity: 0.18,
+ shadowRadius: 20,
+ elevation: 12,
+ minWidth: 280,
+ overflow: 'hidden',
+ }}
+ >
+ {/* SOS prominent — separat, ernst-tonal, nur "SOS" rot */}
+ {
+ onClose();
+ router.push('/urge' as RelativePathString);
+ }}
+ android_ripple={{ color: '#fee2e2' }}
+ >
+
+
+
+
+
+
+ {t('appHeader.sosLabel')}
+
+
+ {t('appHeader.sosTagline')}
+
+
+
+
+
+
+
+
+ {/* Profile · Settings · Games · [Debug DEV] */}
+ {items.map((item) => (
+ {
+ onClose();
+ void item.onSelect();
+ }}
+ android_ripple={{ color: '#e5e7eb' }}
+ style={({ pressed }) => ({
+ backgroundColor: pressed ? '#f5f5f5' : 'transparent',
+ })}
+ >
+
+
+
+ {item.label}
+
+
+
+ ))}
+
+
+
+ {/* Abmelden — neutral, nicht rot */}
+ ({
+ backgroundColor: pressed ? '#f5f5f5' : 'transparent',
+ })}
+ >
+
+
+
+ {t('headerMenu.logout')}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/ApprovedDomainsList.tsx b/apps/rebreak-native/components/profile/ApprovedDomainsList.tsx
new file mode 100644
index 0000000..d642c16
--- /dev/null
+++ b/apps/rebreak-native/components/profile/ApprovedDomainsList.tsx
@@ -0,0 +1,135 @@
+import { useState } from 'react';
+import { View, Text, Pressable, LayoutAnimation, Platform, UIManager } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+
+if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
+ UIManager.setLayoutAnimationEnabledExperimental(true);
+}
+
+export type ApprovedDomain = {
+ domain: string;
+ approvedAt: string;
+};
+
+type Props = {
+ domains: ApprovedDomain[];
+ loading?: boolean;
+};
+
+export function ApprovedDomainsList({ domains, loading }: Props) {
+ const [expanded, setExpanded] = useState(false);
+
+ function toggle() {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setExpanded((v) => !v);
+ }
+
+ return (
+
+ ({
+ alignSelf: 'stretch',
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ backgroundColor: '#ffffff',
+ borderWidth: 1,
+ borderColor: '#e5e5e5',
+ borderRadius: 12,
+ opacity: pressed ? 0.7 : 1,
+ })}
+ >
+
+
+ Approved Domains{' '}
+
+ ({domains.length})
+
+
+
+
+
+ {expanded ? (
+
+ {loading ? (
+
+
+
+
+
+ ) : domains.length === 0 ? (
+
+ Noch keine approved Domains. Submit deine erste in der Community.
+
+ ) : (
+ domains.map((d, idx) => (
+
+
+ {d.domain}
+
+
+ {d.approvedAt}
+
+
+ ))
+ )}
+
+ ) : null}
+
+ );
+}
+
+function SkeletonRow() {
+ return (
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/DemographicsAccordion.tsx b/apps/rebreak-native/components/profile/DemographicsAccordion.tsx
new file mode 100644
index 0000000..0ebe40e
--- /dev/null
+++ b/apps/rebreak-native/components/profile/DemographicsAccordion.tsx
@@ -0,0 +1,621 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ View,
+ Text,
+ Pressable,
+ TextInput,
+ Modal,
+ LayoutAnimation,
+ Platform,
+ UIManager,
+ ScrollView,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+import type { Plan } from '../../hooks/useUserPlan';
+
+if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
+ UIManager.setLayoutAnimationEnabledExperimental(true);
+}
+
+export type Demographics = {
+ birthYear: number | null;
+ gender: string | null;
+ maritalStatus: string | null;
+ profession: string | null;
+ bundesland: string | null;
+ city: string | null;
+};
+
+type Props = {
+ demographics: Demographics;
+ plan: Plan;
+ defaultExpanded?: boolean;
+ onChange?: (next: Demographics) => void;
+ onRevokeConsent?: () => void;
+};
+
+// Select-Optionen — Display-Label DE, value für DB-Persistenz
+const GENDER_OPTIONS: Array<{ label: string; value: string }> = [
+ { label: 'männlich', value: 'male' },
+ { label: 'weiblich', value: 'female' },
+ { label: 'divers', value: 'diverse' },
+ { label: 'keine Angabe', value: 'none' },
+];
+
+const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
+ { label: 'ledig', value: 'single' },
+ { label: 'Partnerschaft', value: 'partnership' },
+ { label: 'verheiratet', value: 'married' },
+ { label: 'geschieden', value: 'divorced' },
+ { label: 'verwitwet', value: 'widowed' },
+ { label: 'keine Angabe', value: 'none' },
+];
+
+// ISO-3166-2:DE — value=ISO, label=DE-Display
+const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
+ { label: 'Baden-Württemberg', value: 'BW' },
+ { label: 'Bayern', value: 'BY' },
+ { label: 'Berlin', value: 'BE' },
+ { label: 'Brandenburg', value: 'BB' },
+ { label: 'Bremen', value: 'HB' },
+ { label: 'Hamburg', value: 'HH' },
+ { label: 'Hessen', value: 'HE' },
+ { label: 'Mecklenburg-Vorpommern', value: 'MV' },
+ { label: 'Niedersachsen', value: 'NI' },
+ { label: 'Nordrhein-Westfalen', value: 'NW' },
+ { label: 'Rheinland-Pfalz', value: 'RP' },
+ { label: 'Saarland', value: 'SL' },
+ { label: 'Sachsen', value: 'SN' },
+ { label: 'Sachsen-Anhalt', value: 'ST' },
+ { label: 'Schleswig-Holstein', value: 'SH' },
+ { label: 'Thüringen', value: 'TH' },
+];
+
+const FIELD_WHY: Record = {
+ birthYear:
+ 'Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe.',
+ gender: 'Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel.',
+ profession:
+ 'Schichtarbeit, Banking-Stress, Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext.',
+ maritalStatus:
+ 'Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir.',
+ bundesland: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
+ city: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
+};
+
+function lookupLabel(options: Array<{ label: string; value: string }>, v: string | null) {
+ if (!v) return null;
+ return options.find((o) => o.value === v)?.label ?? v;
+}
+
+function isComplete(d: Demographics) {
+ return (
+ d.birthYear !== null &&
+ !!d.gender &&
+ !!d.maritalStatus &&
+ !!d.profession &&
+ !!d.bundesland &&
+ !!d.city
+ );
+}
+
+// TODO Phase C: PATCH /api/profile/me/demographics — debounced auto-save (~500ms idle).
+// Bis Endpoint live: lokaler State + onChange-Callback Richtung Parent.
+function mockPersist(_next: Demographics) {
+ // no-op placeholder — Parent ruft echten Endpoint
+}
+
+export function DemographicsAccordion({
+ demographics,
+ plan,
+ defaultExpanded = false,
+ onChange,
+ onRevokeConsent,
+}: Props) {
+ const [expanded, setExpanded] = useState(defaultExpanded);
+ const [local, setLocal] = useState(demographics);
+
+ // Select-Sheet-State
+ const [pickerField, setPickerField] = useState(null);
+
+ // Debounce-Save Ref
+ const saveTimer = useRef | null>(null);
+
+ useEffect(() => {
+ setLocal(demographics);
+ }, [demographics]);
+
+ function toggle() {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setExpanded((v) => !v);
+ }
+
+ function persist(next: Demographics) {
+ setLocal(next);
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ saveTimer.current = setTimeout(() => {
+ mockPersist(next);
+ onChange?.(next);
+ }, 500);
+ }
+
+ function flushSave(next: Demographics) {
+ if (saveTimer.current) clearTimeout(saveTimer.current);
+ mockPersist(next);
+ onChange?.(next);
+ setLocal(next);
+ }
+
+ const completed = isComplete(local);
+ const showProTrialBanner = plan === 'free' && completed;
+
+ return (
+
+ {/* Privacy-Header */}
+ ({
+ opacity: pressed ? 0.7 : 1,
+ backgroundColor: '#ffffff',
+ borderWidth: 1,
+ borderColor: '#e5e5e5',
+ borderRadius: 14,
+ padding: 16,
+ })}
+ >
+
+
+
+ ANONYMER BEITRAG ZUR FORSCHUNG
+
+
+ Optional. Niemals mit Name oder Email verknüpft. Jederzeit löschbar.
+
+
+
+
+
+
+ {expanded ? (
+
+ {/* Pro-Trial-Reward-Banner — nur free + (idealerweise) nicht-vollständig.
+ Wir zeigen ihn aber auch im "completed"-State als sanfte Bestätigung,
+ tatsächliche Trial-Vergabe ist Backend-Sache (Phase C). */}
+ {plan === 'free' ? (
+
+
+
+
+ {showProTrialBanner
+ ? 'Du bekommst 1 Woche Pro geschenkt'
+ : 'Vervollständige dein Profil — 1 Woche Pro geschenkt'}
+
+
+ Mit deinen anonymen Daten machen wir rebreak zur ersten DiGA-zertifizierten
+ Spielsucht-App. Als Dankeschön: 1 Woche Pro.
+
+
+
+ ) : null}
+
+ {/* Birth Year — Number-Input */}
+
+ {
+ const cleaned = raw.replace(/[^0-9]/g, '').slice(0, 4);
+ if (cleaned === '') {
+ persist({ ...local, birthYear: null });
+ return;
+ }
+ const n = parseInt(cleaned, 10);
+ // Erlaube tippen — Validierung beim Blur
+ persist({ ...local, birthYear: Number.isNaN(n) ? null : n });
+ }}
+ onBlur={() => {
+ const n = local.birthYear;
+ if (n !== null && (n < 1920 || n > 2010)) {
+ // ungültig — auf null zurücksetzen
+ flushSave({ ...local, birthYear: null });
+ }
+ }}
+ keyboardType="number-pad"
+ maxLength={4}
+ placeholder="z.B. 1989"
+ placeholderTextColor={colors.textMuted}
+ style={inputStyle}
+ />
+
+
+ {/* Gender — Select */}
+
+ setPickerField('gender')}
+ />
+
+
+ {/* Profession — TextInput */}
+
+ persist({ ...local, profession: t })}
+ onBlur={() => {
+ const trimmed = (local.profession ?? '').trim();
+ flushSave({ ...local, profession: trimmed === '' ? null : trimmed });
+ }}
+ maxLength={80}
+ placeholder="z.B. Pflege, IT, Schichtarbeit"
+ placeholderTextColor={colors.textMuted}
+ style={inputStyle}
+ />
+
+
+ {/* Marital — Select */}
+
+ setPickerField('maritalStatus')}
+ />
+
+
+ {/* Bundesland — Select */}
+
+ setPickerField('bundesland')}
+ />
+
+
+ {/* City — TextInput */}
+
+ persist({ ...local, city: t })}
+ onBlur={() => {
+ const trimmed = (local.city ?? '').trim();
+ flushSave({ ...local, city: trimmed === '' ? null : trimmed });
+ }}
+ maxLength={60}
+ placeholder="z.B. München"
+ placeholderTextColor={colors.textMuted}
+ style={inputStyle}
+ />
+
+
+ {/* Revoke Consent */}
+ ({
+ opacity: pressed ? 0.7 : 1,
+ marginTop: 4,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ borderTopWidth: 1,
+ borderTopColor: 'rgba(0,0,0,0.06)',
+ })}
+ >
+
+ Einwilligung widerrufen
+
+
+
+ ) : null}
+
+ setPickerField(null)}
+ onSelect={(v) => {
+ flushSave({ ...local, gender: v });
+ setPickerField(null);
+ }}
+ />
+ setPickerField(null)}
+ onSelect={(v) => {
+ flushSave({ ...local, maritalStatus: v });
+ setPickerField(null);
+ }}
+ />
+ setPickerField(null)}
+ onSelect={(v) => {
+ flushSave({ ...local, bundesland: v });
+ setPickerField(null);
+ }}
+ />
+
+ );
+}
+
+const inputStyle = {
+ fontSize: 14,
+ color: colors.text,
+ fontFamily: 'Nunito_600SemiBold',
+ paddingVertical: 8,
+ paddingHorizontal: 10,
+ backgroundColor: '#fafafa',
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: '#ececec',
+ minWidth: 140,
+ textAlign: 'right' as const,
+};
+
+function FieldRow({
+ label,
+ why,
+ isLast,
+ children,
+}: {
+ label: string;
+ why: string;
+ isLast?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ {label}
+
+ {children}
+
+
+ {why}
+
+
+ );
+}
+
+function SelectButton({ value, onPress }: { value: string | null; onPress: () => void }) {
+ return (
+ ({
+ opacity: pressed ? 0.6 : 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ paddingVertical: 8,
+ paddingHorizontal: 10,
+ backgroundColor: '#fafafa',
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: '#ececec',
+ minWidth: 140,
+ justifyContent: 'flex-end',
+ })}
+ >
+
+ {value ?? 'auswählen'}
+
+
+
+ );
+}
+
+function SelectSheet({
+ visible,
+ title,
+ options,
+ selectedValue,
+ onClose,
+ onSelect,
+}: {
+ visible: boolean;
+ title: string;
+ options: Array<{ label: string; value: string }>;
+ selectedValue: string | null;
+ onClose: () => void;
+ onSelect: (v: string) => void;
+}) {
+ const sortedOptions = useMemo(() => options, [options]);
+
+ return (
+
+
+ {
+ /* swallow */
+ }}
+ style={{
+ backgroundColor: '#ffffff',
+ borderTopLeftRadius: 18,
+ borderTopRightRadius: 18,
+ paddingHorizontal: 8,
+ paddingTop: 12,
+ paddingBottom: 24,
+ maxHeight: '70%',
+ }}
+ >
+
+
+ {title}
+
+
+
+
+
+
+ {sortedOptions.map((opt) => {
+ const isSelected = opt.value === selectedValue;
+ return (
+ onSelect(opt.value)}
+ style={({ pressed }) => ({
+ opacity: pressed ? 0.6 : 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 14,
+ paddingVertical: 14,
+ borderRadius: 10,
+ backgroundColor: isSelected ? '#f5f8ff' : 'transparent',
+ })}
+ >
+
+ {opt.label}
+
+ {isSelected ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/DigaMissionBanner.tsx b/apps/rebreak-native/components/profile/DigaMissionBanner.tsx
new file mode 100644
index 0000000..4c457f4
--- /dev/null
+++ b/apps/rebreak-native/components/profile/DigaMissionBanner.tsx
@@ -0,0 +1,113 @@
+import { View, Text, Pressable } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+
+type Props = {
+ onDismiss?: () => void;
+ onContribute?: () => void;
+};
+
+export function DigaMissionBanner({ onDismiss, onContribute }: Props) {
+ return (
+
+
+
+
+
+
+
+ 30 Tage geschützt — danke
+
+
+ Rebreak strebt die Anerkennung als DiGA an. Mit ein paar anonymen
+ Angaben hilfst du, die Wirksamkeit zu belegen — damit Krankenkassen
+ die App künftig erstatten können.
+
+
+
+ ({
+ opacity: pressed ? 0.7 : 1,
+ paddingHorizontal: 12,
+ paddingVertical: 7,
+ backgroundColor: '#854d0e',
+ borderRadius: 8,
+ })}
+ >
+
+ Beitragen
+
+
+ ({
+ opacity: pressed ? 0.7 : 1,
+ paddingHorizontal: 12,
+ paddingVertical: 7,
+ borderRadius: 8,
+ })}
+ >
+
+ Später
+
+
+
+
+ ({ opacity: pressed ? 0.5 : 1 })}
+ >
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/ProfileHeader.tsx b/apps/rebreak-native/components/profile/ProfileHeader.tsx
new file mode 100644
index 0000000..b51075e
--- /dev/null
+++ b/apps/rebreak-native/components/profile/ProfileHeader.tsx
@@ -0,0 +1,241 @@
+import { useState } from 'react';
+import { View, Text, Pressable, Image } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+import { resolveAvatar } from '../../lib/resolveAvatar';
+import type { Plan } from '../../hooks/useUserPlan';
+
+export type AuthProvider = 'apple' | 'google' | 'email';
+
+type Props = {
+ nickname: string;
+ email: string;
+ avatar: string | null;
+ plan: Plan;
+ memberSince: string;
+ provider: AuthProvider;
+ showDemographicsHint?: boolean;
+ onEditAvatar?: () => void;
+ onEditNickname?: () => void;
+ onDemographicsHintPress?: () => void;
+};
+
+const planLabel: Record = {
+ free: 'Free',
+ pro: 'Pro',
+ legend: 'Legend',
+};
+
+const planColors: Record = {
+ free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' },
+ pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' },
+ legend: { bg: '#fef9c3', text: '#854d0e', border: '#fde68a' },
+};
+
+export function ProfileHeader({
+ nickname,
+ email,
+ avatar,
+ plan,
+ memberSince,
+ provider,
+ showDemographicsHint,
+ onEditAvatar,
+ onEditNickname,
+ onDemographicsHintPress,
+}: Props) {
+ const [imageFailed, setImageFailed] = useState(false);
+ const avatarUrl = resolveAvatar(avatar, nickname);
+ const initials = nickname.slice(0, 2).toUpperCase();
+ const showImage = !!avatar && !imageFailed;
+
+ const planStyle = planColors[plan];
+
+ const providerPillLabel =
+ provider === 'apple' ? 'via Apple Sign-In'
+ : provider === 'google' ? 'via Google Sign-In'
+ : null;
+
+ return (
+
+ {/* Avatar — Pressable; Camera-Badge ist eigene Pressable (vorher nur dekoratives View) */}
+ ({
+ position: 'relative',
+ opacity: pressed ? 0.85 : 1,
+ })}
+ >
+
+ {showImage ? (
+ setImageFailed(true)}
+ style={{ width: 92, height: 92, borderRadius: 46 }}
+ />
+ ) : (
+
+ {initials}
+
+ )}
+
+
+ {/* Camera-Badge — iOS-Photos-Pattern: blauer Kreis, weißes Icon */}
+
+
+
+
+
+ {/* Nickname — ganze Zeile Pressable (iOS-Settings-Pattern), kein hässliches Pencil */}
+ ({
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 16,
+ gap: 6,
+ opacity: pressed ? 0.5 : 1,
+ })}
+ >
+
+ {nickname}
+
+
+
+
+ {providerPillLabel ? (
+
+
+
+ {providerPillLabel}
+
+
+ ) : (
+
+ {email}
+
+ )}
+
+
+
+
+ {planLabel[plan].toUpperCase()}
+
+
+
+ Mitglied seit {memberSince}
+
+
+
+ {/* Freundlicher Hint statt Progress-Bar — nur sichtbar wenn Demographics unvollständig */}
+ {showDemographicsHint ? (
+ ({
+ alignSelf: 'stretch',
+ marginTop: 14,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ borderRadius: 12,
+ backgroundColor: '#f5f8ff',
+ borderWidth: 1,
+ borderColor: '#dbe5ff',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ opacity: pressed ? 0.7 : 1,
+ })}
+ >
+
+
+ Hilf uns rebreak besser zu machen — fülle deine anonymen Daten aus.
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/StatsBar.tsx b/apps/rebreak-native/components/profile/StatsBar.tsx
new file mode 100644
index 0000000..0ae5c1b
--- /dev/null
+++ b/apps/rebreak-native/components/profile/StatsBar.tsx
@@ -0,0 +1,116 @@
+import { View, Text, Pressable } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+
+type Props = {
+ postsCount: number;
+ followersCount: number;
+ approvedDomainsCount: number;
+ onPostsPress?: () => void;
+ onFollowersPress?: () => void;
+ onApprovedDomainsPress?: () => void;
+};
+
+type CardProps = {
+ value: string;
+ label: string;
+ icon?: React.ComponentProps['name'];
+ onPress?: () => void;
+};
+
+function StatCard({ value, label, icon, onPress }: CardProps) {
+ return (
+ ({
+ flex: 1,
+ opacity: pressed ? 0.6 : 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingVertical: 16,
+ paddingHorizontal: 8,
+ })}
+ >
+
+
+ {value}
+
+ {icon ? (
+
+ ) : null}
+
+
+ {label}
+
+
+ );
+}
+
+function Divider() {
+ return (
+
+ );
+}
+
+/**
+ * Community-Stats: 3 prominente Cards in einer zentrierten Reihe.
+ * - Posts / Follower / Approved Domains
+ * - Approved Domains: PLAIN INTEGER (kein Cap), Trophy-Icon als Community-Beitrag-Hint
+ */
+export function StatsBar({
+ postsCount,
+ followersCount,
+ approvedDomainsCount,
+ onPostsPress,
+ onFollowersPress,
+ onApprovedDomainsPress,
+}: Props) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/StreakSection.tsx b/apps/rebreak-native/components/profile/StreakSection.tsx
new file mode 100644
index 0000000..91c769a
--- /dev/null
+++ b/apps/rebreak-native/components/profile/StreakSection.tsx
@@ -0,0 +1,232 @@
+import { View, Text } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+
+export type CooldownEntry = {
+ id: string;
+ startedAt: string;
+ durationLabel: string;
+ status: 'active' | 'resolved' | 'cancelled';
+ reason: string | null;
+};
+
+type Props = {
+ currentDays: number;
+ longestDays: number;
+ startDate: string;
+ cooldowns: CooldownEntry[];
+};
+
+const statusLabel: Record = {
+ active: 'aktiv',
+ resolved: 'beendet',
+ cancelled: 'abgebrochen',
+};
+
+const statusColor: Record = {
+ active: { bg: '#fff7ed', text: '#c2410c' },
+ resolved: { bg: '#f0fdf4', text: '#15803d' },
+ cancelled: { bg: '#f5f5f5', text: '#737373' },
+};
+
+export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) {
+ return (
+
+
+
+
+ STREAK
+
+
+
+
+
+
+ {currentDays}
+
+
+ Tage geschützt
+
+
+
+ seit {startDate}
+
+
+ Längste Streak: {longestDays} Tage
+
+
+
+ {cooldowns.length > 0 ? (
+
+
+ COOLDOWN-VERLAUF
+
+
+
+ {cooldowns.map((c, idx) => {
+ const isLast = idx === cooldowns.length - 1;
+ const colorPair = statusColor[c.status];
+ return (
+
+
+
+ {!isLast ? (
+
+ ) : null}
+
+
+
+
+
+
+ {c.startedAt}
+
+
+ {c.durationLabel}
+
+
+
+
+ {statusLabel[c.status].toUpperCase()}
+
+
+
+ {c.reason ? (
+
+ {c.reason}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/UrgeStatsCard.tsx b/apps/rebreak-native/components/profile/UrgeStatsCard.tsx
new file mode 100644
index 0000000..60871b0
--- /dev/null
+++ b/apps/rebreak-native/components/profile/UrgeStatsCard.tsx
@@ -0,0 +1,209 @@
+import { View, Text } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { colors } from '../../lib/theme';
+
+export type HelpedByEntry = {
+ key: 'breathing' | 'game' | 'talk' | 'other';
+ label: string;
+ count: number;
+};
+
+type Props = {
+ sessions: number;
+ overcome: number;
+ helpedBy: HelpedByEntry[];
+ topEmotion: string | null;
+};
+
+export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Props) {
+ const overcomePct = sessions > 0 ? Math.round((overcome / sessions) * 100) : 0;
+ const totalHelped = helpedBy.reduce((sum, h) => sum + h.count, 0);
+
+ return (
+
+
+
+
+ LYRA INSIGHTS
+
+
+
+
+
+ Letzte 30 Tage
+
+
+ {sessions === 0 ? (
+
+ Noch keine SOS-Session. Lyra ist da, wenn du sie brauchst.
+
+ ) : (
+ <>
+
+
+ {sessions}
+
+
+ SOS-Sessions, {overcome} bewältigt
+
+
+
+
+
+
+
+ {overcomePct}% bewältigt
+
+
+ {totalHelped > 0 ? (
+
+
+ Was hat geholfen
+
+ {helpedBy.map((h) => {
+ const pct = totalHelped > 0 ? (h.count / totalHelped) * 100 : 0;
+ return (
+
+
+
+ {h.label}
+
+
+ {h.count} {h.count === 1 ? 'Session' : 'Sessions'}
+
+
+
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ {topEmotion ? (
+
+ Häufigste Emotion: {topEmotion}
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/urge/UrgeGames.tsx b/apps/rebreak-native/components/urge/UrgeGames.tsx
index dfeabbb..633f9dd 100644
--- a/apps/rebreak-native/components/urge/UrgeGames.tsx
+++ b/apps/rebreak-native/components/urge/UrgeGames.tsx
@@ -121,13 +121,16 @@ export function SnakeGame({
{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 },
]);
const [food, setFood] = useState({ row: 3, col: 10 });
+ const snakeRef = useRef(snake);
+ const foodRef = useRef(food);
+ useEffect(() => { snakeRef.current = snake; }, [snake]);
+ useEffect(() => { foodRef.current = food; }, [food]);
const dirRef = useRef('right');
const nextDirRef = useRef('right');
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [activeDPad, setActiveDPad] = useState('right');
- const [, forceRender] = useState(0);
const intervalRef = useRef | null>(null);
// Load high score
@@ -164,43 +167,47 @@ export function SnakeGame({
setTimeout(() => onComplete(finalScore), 500);
}
- // Game tick loop
+ // Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
+ // Reading snake/food from refs avoids stale closures and prevents duplicate setFood calls
+ // when React (StrictMode) invokes a state-updater twice.
useEffect(() => {
if (gameOver) return;
intervalRef.current = setInterval(() => {
dirRef.current = nextDirRef.current;
- setSnake((prev) => {
- const head = prev[0];
- if (!head) return prev;
- const next: Pos = { row: head.row, col: head.col };
- if (dirRef.current === 'up') next.row--;
- else if (dirRef.current === 'down') next.row++;
- else if (dirRef.current === 'left') next.col--;
- else if (dirRef.current === 'right') next.col++;
- if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) {
- setTimeout(() => endGame(score), 0);
- return prev;
- }
- if (prev.some((s) => s.row === next.row && s.col === next.col)) {
- setTimeout(() => endGame(score), 0);
- return prev;
- }
- const ate = next.row === food.row && next.col === food.col;
- const newSnake = [next, ...prev];
- if (!ate) newSnake.pop();
- else {
- setScore((s) => s + 1);
- setFood(randomFood(newSnake));
- }
- return newSnake;
- });
- forceRender((x) => x + 1);
+ const prev = snakeRef.current;
+ const head = prev[0];
+ if (!head) return;
+ const next: Pos = { row: head.row, col: head.col };
+ if (dirRef.current === 'up') next.row--;
+ else if (dirRef.current === 'down') next.row++;
+ else if (dirRef.current === 'left') next.col--;
+ else if (dirRef.current === 'right') next.col++;
+ if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) {
+ endGame(score);
+ return;
+ }
+ if (prev.some((s) => s.row === next.row && s.col === next.col)) {
+ endGame(score);
+ return;
+ }
+ const currentFood = foodRef.current;
+ const ate = next.row === currentFood.row && next.col === currentFood.col;
+ const newSnake = [next, ...prev];
+ if (!ate) newSnake.pop();
+ snakeRef.current = newSnake;
+ setSnake(newSnake);
+ if (ate) {
+ const newFood = randomFood(newSnake);
+ foodRef.current = newFood;
+ setFood(newFood);
+ setScore((s) => s + 1);
+ }
}, SNAKE_TICK_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [gameOver, food, score, highScore]);
+ }, [gameOver, score, highScore]);
// Swipe gestures
const panResponder = useMemo(
@@ -269,9 +276,9 @@ export function SnakeGame({
: null;
return (
-
+
{/* Header */}
-
+
{lyraMessage}
@@ -322,8 +329,8 @@ export function SnakeGame({
onDPad('up')} />
onDPad('left')} />
-
-
+
+
onDPad('right')} />
@@ -341,40 +348,44 @@ export function SnakeGame({
);
}
+// Platform-native D-Pad button: iOS uses system-blue tinted circle (SF-symbol look),
+// Android uses Material elevated surface with ripple.
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
const icons: Record = {
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
};
- // FIX 1 (prev agent): icon color follows pressed-OR-active so it stays visible against dark pressed-bg.
- // FIX 2 (this agent): idle button was #ffffff on a #ffffff screen → invisible. Idle is now light-gray
- // with stronger border, pressed becomes mid-gray, active stays dark. Guarantees ≥ 3:1 contrast in all states.
- const isHighlighted = active;
+ const isIOS = Platform.OS === 'ios';
+ const tint = '#007aff';
return (
{ tapHaptic(); onPress(); }}
hitSlop={12}
- android_ripple={{ color: 'rgba(31,41,55,0.18)', borderless: true, radius: 36 }}
- style={({ pressed }) => ({
- width: 64, height: 64, borderRadius: 32,
- backgroundColor: isHighlighted ? '#1f2937' : (pressed ? '#d1d5db' : '#f3f4f6'),
- borderWidth: 1.5,
- borderColor: isHighlighted ? '#1f2937' : (pressed ? '#6b7280' : '#9ca3af'),
- alignItems: 'center', justifyContent: 'center',
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: pressed ? 0.06 : 0.12,
- shadowRadius: 4,
- elevation: pressed ? 1 : 3,
- transform: [{ scale: pressed ? 0.94 : 1 }],
- })}
+ android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
+ style={({ pressed }) => {
+ const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff';
+ const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5';
+ const bgActive = tint;
+ const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle);
+ return {
+ width: 60, height: 60, borderRadius: 30,
+ backgroundColor: bg,
+ alignItems: 'center', justifyContent: 'center',
+ ...(isIOS ? {} : {
+ elevation: active ? 4 : 2,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.15,
+ shadowRadius: 2,
+ }),
+ transform: [{ scale: pressed && isIOS ? 0.96 : 1 }],
+ };
+ }}
>
- {({ pressed }) => (
-
- )}
+
);
}
@@ -952,9 +963,9 @@ export function TetrisGame({
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
return (
-
+
{/* Header */}
-
+
{lyraMessage}
@@ -966,7 +977,7 @@ export function TetrisGame({
{/* Board */}
-
+
{displayBoard.map((row, y) => (
@@ -993,7 +1004,7 @@ export function TetrisGame({
{/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */}
-
+
@@ -1023,7 +1034,7 @@ export function TetrisGame({
{/* Controls — Move Pad (links) + Action Pad (rechts) */}
-
+
{/* Move Pad */}
diff --git a/apps/rebreak-native/lib/sosTtsBenchmark.ts b/apps/rebreak-native/lib/sosTtsBenchmark.ts
index 4b2c6e7..1eaccc2 100644
--- a/apps/rebreak-native/lib/sosTtsBenchmark.ts
+++ b/apps/rebreak-native/lib/sosTtsBenchmark.ts
@@ -44,13 +44,15 @@ type MarkerEntry = {
export class BenchSession {
readonly t0: number;
readonly provider: string;
+ readonly llm: string;
readonly label: string;
private entries: MarkerEntry[] = [];
private printed = false;
- constructor(opts: { provider: string; label?: string }) {
+ constructor(opts: { provider: string; llm?: string; label?: string }) {
this.t0 = Date.now();
this.provider = opts.provider;
+ this.llm = opts.llm ?? 'unknown';
this.label = opts.label ?? 'sos-turn';
}
@@ -73,7 +75,8 @@ export class BenchSession {
};
const stages = {
- provider: this.provider,
+ tts: this.provider,
+ llm: this.llm,
label: this.label,
'req→session': fmt(get('session-post-done')),
'lyra-ttfb': fmt(get('sse-first-chunk')),
@@ -91,7 +94,7 @@ export class BenchSession {
// console.table mit allen Markern (für strukturierte Inspektion).
// eslint-disable-next-line no-console
console.log(
- `[bench] ${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
+ `[bench] LLM=${this.llm} TTS=${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
stages,
);
// eslint-disable-next-line no-console
@@ -99,7 +102,7 @@ export class BenchSession {
}
/** Snapshot für UI-Overlays (Debug-Drawer etc.). */
- snapshot(): { provider: string; label: string; entries: MarkerEntry[] } {
- return { provider: this.provider, label: this.label, entries: [...this.entries] };
+ snapshot(): { provider: string; llm: string; label: string; entries: MarkerEntry[] } {
+ return { provider: this.provider, llm: this.llm, label: this.label, entries: [...this.entries] };
}
}
diff --git a/apps/rebreak-native/lib/tabIcons.ts b/apps/rebreak-native/lib/tabIcons.ts
index 5670d5c..519c351 100644
--- a/apps/rebreak-native/lib/tabIcons.ts
+++ b/apps/rebreak-native/lib/tabIcons.ts
@@ -15,7 +15,7 @@ import { Platform, type ImageSourcePropType } from 'react-native';
export type TabKey = 'home' | 'chat' | 'coach' | 'blocker' | 'mail';
-const ANDROID_ICONS: Record = {
+const ANDROID_ICONS: Partial> = {
home: require('../assets/tabs/home.png'),
chat: require('../assets/tabs/chatbubble.png'),
coach: require('../assets/tabs/sparkles.png'),
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index c84d8a1..fc40e5e 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -91,18 +91,34 @@
},
"appHeader": {
"appName": "ReBreak",
- "sosLabel": "SOS — Atemübung",
- "sosSubtitle": "Sofort-Hilfe bei Druck",
+ "sosLabel": "SOS",
+ "sosTagline": "wir sind für dich da",
+ "sosSubtitle": "Hier lang wenn du Hilfe brauchst",
"editProfile": "Profil bearbeiten",
"settings": "Einstellungen",
"signOut": "Abmelden"
},
+ "headerMenu": {
+ "profile": "Profil",
+ "settings": "Einstellungen",
+ "games": "ReBreak Games",
+ "debug": "Debug",
+ "logout": "Abmelden"
+ },
"tabs": {
"home": "Home",
"chat": "Chat",
"coach": "Coach",
"blocker": "Blocker",
- "mail": "Mail"
+ "mail": "Mail",
+ "profile": "Profil"
+ },
+ "games": {
+ "title": "ReBreak Games",
+ "subtitle": "Casual spielen ohne SOS — Memory, Snake, Tetris und Tic-Tac-Toe.",
+ "back_to_picker": "Spiele",
+ "last_score": "Score: {{score}}",
+ "skeleton_footer": "Skeleton — Highscore-Leaderboard kommt in Phase C"
},
"home": {
"tagline": "Du gehst nicht allein.",
@@ -401,15 +417,43 @@
"devices": "Geräte",
"devices_desc": "Registrierte Geräte verwalten",
"subscription": "Abonnement",
+ "subscription_desc": "Plan & Upgrade-Pfad",
"plan_free": "Free",
"push_notifications": "Push-Benachrichtigungen",
"streak_reminders": "Streak-Erinnerungen",
"language": "Sprache",
+ "language_desc": "Deutsch / Englisch",
"language_current": "Deutsch",
"upgrade_cta": "Auf Pro upgraden — 29 €/Jahr",
"delete_account": "Konto löschen",
"delete_desc": "Alle Daten werden unwiderruflich gelöscht.",
- "sign_out": "Abmelden"
+ "sign_out": "Abmelden",
+ "coming_soon_title": "Coming soon",
+ "coming_soon_desc": "Settings werden in Phase 3 wired-up. Aktuell nur Skeleton.",
+ "soon_badge": "Soon",
+ "skeleton_footer": "Settings-Skeleton — siehe ops/UI_MIGRATION_PLAN.md",
+ "section_profile": "Profil",
+ "profile_edit": "Nickname & Avatar",
+ "profile_edit_desc": "Nickname, Avatar-Bild, persönliche Daten",
+ "profile_avatar": "Avatar wählen",
+ "profile_avatar_desc": "Preset-Library oder eigenes Foto",
+ "section_theme": "Theme & Sprache",
+ "theme": "Theme",
+ "theme_desc": "Hell / Dunkel / System",
+ "section_notifications": "Benachrichtigungen",
+ "notifications_push": "Push-Benachrichtigungen",
+ "notifications_push_desc": "Einzelne Kategorien an/aus",
+ "notifications_streak": "Streak-Erinnerungen",
+ "notifications_streak_desc": "Tägliche Anstöße zum Dranbleiben",
+ "section_devices": "Geräte & Abo",
+ "section_lyra": "Lyra (Legend)",
+ "lyra_voice": "Lyra-Stimme",
+ "lyra_voice_desc": "Voice-Picker — verfügbar im Legend-Plan",
+ "section_debug": "Debug",
+ "debug_llm": "LLM-Provider",
+ "debug_llm_desc": "Modell & Prompt-Tuning (DEV)",
+ "debug_tts": "TTS-Provider",
+ "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)"
},
"urge": {
"title": "SOS — Atemübung",
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index ba1c229..b5b5569 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -91,18 +91,34 @@
},
"appHeader": {
"appName": "ReBreak",
- "sosLabel": "SOS — Breathing exercise",
- "sosSubtitle": "Instant help under pressure",
+ "sosLabel": "SOS",
+ "sosTagline": "we're here for you",
+ "sosSubtitle": "Tap if you need help",
"editProfile": "Edit profile",
"settings": "Settings",
"signOut": "Sign out"
},
+ "headerMenu": {
+ "profile": "Profile",
+ "settings": "Settings",
+ "games": "ReBreak Games",
+ "debug": "Debug",
+ "logout": "Sign out"
+ },
"tabs": {
"home": "Home",
"chat": "Chat",
"coach": "Coach",
"blocker": "Blocker",
- "mail": "Mail"
+ "mail": "Mail",
+ "profile": "Profile"
+ },
+ "games": {
+ "title": "ReBreak Games",
+ "subtitle": "Casual play outside SOS — Memory, Snake, Tetris and Tic-Tac-Toe.",
+ "back_to_picker": "Games",
+ "last_score": "Score: {{score}}",
+ "skeleton_footer": "Skeleton — Highscore leaderboard coming in Phase C"
},
"home": {
"tagline": "You're not walking alone.",
@@ -401,15 +417,43 @@
"devices": "Devices",
"devices_desc": "Manage registered devices",
"subscription": "Subscription",
+ "subscription_desc": "Plan & upgrade path",
"plan_free": "Free",
"push_notifications": "Push notifications",
"streak_reminders": "Streak reminders",
"language": "Language",
+ "language_desc": "German / English",
"language_current": "English",
"upgrade_cta": "Upgrade to Pro — €29/year",
"delete_account": "Delete account",
"delete_desc": "All data will be permanently deleted.",
- "sign_out": "Sign out"
+ "sign_out": "Sign out",
+ "coming_soon_title": "Coming soon",
+ "coming_soon_desc": "Settings will be wired up in Phase 3. Currently skeleton only.",
+ "soon_badge": "Soon",
+ "skeleton_footer": "Settings skeleton — see ops/UI_MIGRATION_PLAN.md",
+ "section_profile": "Profile",
+ "profile_edit": "Nickname & avatar",
+ "profile_edit_desc": "Nickname, avatar image, personal data",
+ "profile_avatar": "Choose avatar",
+ "profile_avatar_desc": "Preset library or your own photo",
+ "section_theme": "Theme & language",
+ "theme": "Theme",
+ "theme_desc": "Light / Dark / System",
+ "section_notifications": "Notifications",
+ "notifications_push": "Push notifications",
+ "notifications_push_desc": "Toggle individual categories",
+ "notifications_streak": "Streak reminders",
+ "notifications_streak_desc": "Daily nudges to stay on track",
+ "section_devices": "Devices & subscription",
+ "section_lyra": "Lyra (Legend)",
+ "lyra_voice": "Lyra voice",
+ "lyra_voice_desc": "Voice picker — Legend-plan exclusive",
+ "section_debug": "Debug",
+ "debug_llm": "LLM provider",
+ "debug_llm_desc": "Model & prompt tuning (DEV)",
+ "debug_tts": "TTS provider",
+ "debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)"
},
"urge": {
"title": "SOS — Breathing exercise",
diff --git a/ops/GAMES_1V1_MIGRATION_PLAN.md b/ops/GAMES_1V1_MIGRATION_PLAN.md
new file mode 100644
index 0000000..d2bd231
--- /dev/null
+++ b/ops/GAMES_1V1_MIGRATION_PLAN.md
@@ -0,0 +1,259 @@
+# 1v1 Games Migration Plan (Nuxt → rebreak-native)
+
+Status: Recon abgeschlossen 2026-05-07. Read-only Analyse, kein Code-Touch.
+
+Author scope: Migration der bestehenden Nuxt-1v1-Implementierung (TicTacToe + Memory) aus `~/mono/trucko-monorepo/apps/rebreak/` in die neue React-Native-App `~/mono/rebreak-monorepo/apps/rebreak-native/`. Letzter Schritt vor finalem Nuxt-Cutover (DiGA).
+
+---
+
+## 1. Status quo Nuxt-Implementierung
+
+### 1.1 Frontend (Vue/Nuxt)
+
+| Datei | Zweck |
+|---|---|
+| `~/mono/trucko-monorepo/apps/rebreak/app/pages/app/game/[challengeId].vue` (802 LOC) | Haupt-Game-Page. Lobby (Waiting), Live-Board für TicTacToe + Memory, Status, Lyra-Bubble, Tabs (History + Ranking), Rematch, Live-Share-Toggle. Subscribed Supabase-Realtime auf `rebreak.game_challenges`-Row. |
+| `~/mono/trucko-monorepo/apps/rebreak/app/components/sos/GameTicTacToe.vue` | Solo-Modus mit Lyra-AI. Enthält "Gegen echten Spieler"-Button (Z. 73-76) — POST `/api/games/challenge` + Redirect. |
+| `~/mono/trucko-monorepo/apps/rebreak/app/components/sos/GameMemory.vue` | Solo-Memory mit "Gegen echten Spieler"-Button (Z. 48-52) — POST `/api/games/challenge-memory` + Redirect. |
+| `~/mono/trucko-monorepo/apps/rebreak/app/components/CommunityPostCard.vue` | Rendert "Challenge annehmen"-Button für Community-Posts mit `category="challenge"` (Z. 288, 469-479). |
+| `~/mono/trucko-monorepo/apps/rebreak/app/stores/community.ts` | Pinia-Store, hält `challengeId` an Posts (Z. 8, 250). |
+
+**File-Count Frontend: 5 relevante Vue-Files (1 Page + 2 Solo-Game-Components mit 1v1-Hook + 1 PostCard + 1 Store).**
+
+### 1.2 Backend (Nuxt-Server, Nitro)
+
+Backend liegt **nicht** in einem separaten trucko-backend-Service, sondern im selben Nuxt-Projekt unter `apps/rebreak/server/`. Endpoints:
+
+| Endpoint | File |
+|---|---|
+| `POST /api/games/challenge` | `server/api/games/challenge.post.ts` (38 LOC) — TicTacToe-Challenge erzeugen + Community-Post |
+| `POST /api/games/challenge-memory` | `server/api/games/challenge-memory.post.ts` (62 LOC) — Memory-Challenge erzeugen (16 Karten, shuffled) |
+| `GET /api/games/challenge/[id]` | `server/api/games/challenge/[id].get.ts` (16 LOC) — Lade Challenge-State |
+| `POST /api/games/challenge/[id]/accept` | `server/api/games/challenge/[id]/accept.post.ts` (35 LOC) — Gegner tritt bei, Status: OPEN → ACTIVE |
+| `POST /api/games/challenge/[id]/move` | `server/api/games/challenge/[id]/move.post.ts` (109 LOC) — TicTacToe-Move; Win-Check, Score-Update, Post-Cleanup |
+| `POST /api/games/challenge/[id]/memory-move` | `server/api/games/challenge/[id]/memory-move.post.ts` (152 LOC) — Memory-Move (Flip/Match/Mismatch) |
+| `POST /api/games/challenge/[id]/rematch` | `server/api/games/challenge/[id]/rematch.post.ts` (64 LOC) — Neue Challenge mit Gegner pre-set, status=ACTIVE |
+| `POST /api/games/challenge/[id]/live-toggle` | `server/api/games/challenge/[id]/live-toggle.post.ts` (35 LOC) — `isLive`-Flag für Spectators |
+| `GET /api/games/history` | `server/api/games/history.get.ts` (44 LOC) — Spielhistorie (alle, oder vs Gegner) |
+| `GET /api/games/ranking` | `server/api/games/ranking.get.ts` (15 LOC) — Top-Spieler-Liste |
+
+**File-Count Backend: 10 Endpoints, ~570 LOC.**
+
+### 1.3 DB-Schema
+
+Aus `~/mono/trucko-monorepo/apps/rebreak/prisma/schema.prisma`:
+
+- `enum GameChallengeStatus` (Z. 424): `OPEN | ACTIVE | FINISHED | CANCELLED`
+- `model GameChallenge` (Z. 433-452, Tabelle `rebreak.game_challenges`): id, challengerId, challengerName, opponentId, opponentName, status, board (TEXT, default `---------`), currentTurn, winner, postId, gameType (default "tictactoe"), isLive, memoryState (Json), timestamps.
+- `model GameScore` (Z. 470, Tabelle `rebreak.game_scores`): userId PK, playerName, wins, losses, draws, points (3 für Sieg, 1 für Unentschieden).
+- `model GameRating` (Z. 483) und `GameHighScore` (Z. 496) — gehören zum Solo-Mode, irrelevant für 1v1, aber bereits portiert.
+
+Migrations-SQL:
+- `~/mono/trucko-monorepo/apps/rebreak/prisma/migrations/add_game_challenges.sql` — Enum, Table, Indexes, `ALTER PUBLICATION supabase_realtime ADD TABLE rebreak.game_challenges`
+- `~/mono/trucko-monorepo/apps/rebreak/prisma/migrations/add_game_challenges_rls.sql` — RLS-Policies (read/insert/update für challenger + opponent via `auth.uid()`)
+- Spätere Patches haben `gameType`, `isLive`, `memoryState` hinzugefügt (in den live-DB Tabelle vorhanden, kein eigenes Migration-File gefunden — Schema-Drift-Verdacht in Nuxt).
+
+### 1.4 State-Sync-Mechanismus (1 Satz)
+
+**Server-authoritative State in Postgres (`rebreak.game_challenges`-Row); Frontend mutiert via REST-POST und subscribed parallel auf Supabase-Realtime `postgres_changes` UPDATE-Events der eigenen Row → keine Polling, keine WebSocket-Eigenbau.**
+
+### 1.5 Datenflussdiagramm (ASCII)
+
+```
+ Spieler A (Challenger) Spieler B (Opponent)
+ ───────────────────── ─────────────────────
+ │ │
+ POST /api/games/challenge │
+ │ │
+ ▼ │
+ ┌──────────────────────┐ │
+ │ game_challenges │ communityPost.challengeId │
+ │ status=OPEN │◀────────────────────────────────┐│
+ │ board=--------- │ ││
+ └──────────┬───────────┘ ││
+ │ ▼
+ │ GET /api/community/posts
+ │ (sees challenge card)
+ │ │
+ │ POST /api/games/challenge/[id]/accept
+ │ │
+ ▼ ▼
+ ┌────────────────────────────────────────────────────────────┐
+ │ game_challenges status=ACTIVE opponent_id=B │
+ └──────────────────────────┬─────────────────────────────────┘
+ │
+ │ Supabase Realtime (postgres_changes)
+ │ channel = `game::`
+ │ filter = id=eq.
+ ▼
+ ┌──────────────────────────────┐
+ │ both clients update UI │
+ └──────────┬───────────────────┘
+ │
+ loop until FINISHED:
+ │
+ POST /api/games/challenge/[id]/move (or memory-move)
+ │
+ ▼
+ ┌────────────────────────────────────────────────────────────┐
+ │ Server validates turn + writes new board / memoryState │
+ │ on win/draw → upsert game_scores, delete community post │
+ └──────────────────────────┬─────────────────────────────────┘
+ │ Realtime UPDATE → both clients
+ ▼
+ ┌──────────────────────────────┐
+ │ FINISHED screen + Rematch │
+ └──────────────────────────────┘
+```
+
+---
+
+## 2. Migration-Plan
+
+### Phase A — Backend-Endpoints in rebreak-monorepo
+
+**Status: BEREITS PORTIERT.** Verifiziert per `diff`:
+- `~/mono/rebreak-monorepo/backend/server/api/games/challenge.post.ts` ist byte-identisch mit Nuxt.
+- `~/mono/rebreak-monorepo/backend/server/api/games/challenge/[id]/move.post.ts` ist byte-identisch.
+- Alle 10 Endpoints existieren bereits unter `~/mono/rebreak-monorepo/backend/server/api/games/`.
+
+→ **Aufwand Phase A: 0 h.** Nur ein leichter Smoke-Test (curl Request mit Bearer-Token gegen den staging-Nitro) zur Bestätigung dass die Endpoints im Nitro-Prod-Build aktiv sind.
+
+### Phase B — DB-Migrations für game_sessions
+
+**Status: BEREITS PORTIERT.** Schema verifiziert:
+- `enum GameChallengeStatus` in `~/mono/rebreak-monorepo/backend/prisma/schema.prisma` Z. 424 vorhanden.
+- `model GameChallenge`, `GameScore`, `GameRating`, `GameHighScore` alle vorhanden.
+
+**Offene Punkte (klein):**
+1. SQL-Migration unter `backend/prisma/migrations/` muss verifiziert werden — sind `gameType`, `isLive`, `memoryState`-Spalten in einer eigenen Migration angelegt? Falls nein: ein konsolidiertes `add_game_challenges.sql` nachziehen.
+2. RLS-Policies und `ALTER PUBLICATION supabase_realtime ADD TABLE rebreak.game_challenges` müssen am Staging-DB-Cluster bestätigt werden (gleicher DB für Nuxt + RN-Backend, also vermutlich schon aktiv).
+
+→ **Aufwand Phase B: 1-2 h** (SQL-Audit + ggf. ein Catch-up-Migration-File).
+
+### Phase C — RN-UI-Komponenten
+
+**Status: KOMPLETT NEU.** RN-App hat aktuell:
+- `apps/rebreak-native/components/urge/UrgeGames.tsx` (1067 LOC) — Solo-Mode für Memory/TicTacToe/Snake/Tetris.
+- `apps/rebreak-native/app/games.tsx` — Standalone-Games-Page (Solo).
+- KEIN Community-Komponent, KEIN Game-Page für 1v1.
+
+**Zu erstellen:**
+
+1. `apps/rebreak-native/app/(app)/game/[challengeId].tsx` — Pendant zu `pages/app/game/[challengeId].vue`. RN-Expo-Router-File. Ports:
+ - Loading + Lobby (`OPEN`-Status, Waiting-Screen mit Cancel-Button)
+ - TicTacToe-Board (3x3 Grid, X/O-Marker, WinLine-Highlight) — `Pressable`-Cells statt `