From e76be7ee789a3bdae465d92153e4f1644aa7dbb2 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 7 May 2026 18:22:58 +0200 Subject: [PATCH] feat(profile): Profile-Page komplett + Header-Dropdown + UI-Pattern-Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile (3 Iterationen): - app/profile/index.tsx + components/profile/* (Header, StatsBar, Approved, Streak, UrgeStats, Demographics, DigaMissionBanner) - echte Live-Daten via useMe-Hook (Avatar/Nickname/Plan/Email/Provider-Pill) - Demographics mit echten Inputs (TextInput + Bottom-Sheet-Selects), debounced auto-save, Pro-Trial-Reward-Banner, Mikro-Why-Texte - Approved Domains als plain integer (KEIN Plan-Slot/Cap) - Friendly Hint-Text statt Progress-Bar (alignSelf:'stretch' Pattern) - StatsBar zentriert mit 3 prominenten Cards (vertikale Dividers) - Cooldown-Timeline als Liste mit 1px-Rail - ApprovedDomainsList: Collapse-Chevron rechts in Title-Row (Pattern-Fix) - Eigene vs fremde Profile-Ansicht streng getrennt (DSGVO/Anonymität) Header-Dropdown (kein 3-Punkte-Icon): - Avatar als Trigger im AppHeader (User-Wunsch) - Custom-Modal beide Plattformen, Card-Style - SOS prominent oben (nur Wort 'SOS' rot, Tagline 'wir sind für dich da' klein darunter) - Profile/Settings/Games/Debug(__DEV__)/Logout - Logout neutral (nicht rot — Recovery-tonal) - AppHeader: neue showBack + title Props für Sub-Routes Routes (Stub bis Phase C): - app/profile/[userId].tsx — anonym (nur public-Stats) - app/settings.tsx — Coming-Soon-Skeleton - app/games.tsx — Standalone Games-Page mit GameCard-Grid - app/debug.tsx — __DEV__-only Game-Picker (Migration aus Nuxt): - components/games/{GameCard, StarRating, GameRatingStars} - 2x2 Grid, 56pt SVG-Icons (inline aus components/urge/gameSvgs.ts) - Live-Backend /api/games/ratings (silent-fail) - Re-use UrgeGames.tsx ohne TTS/Cooldown-Loop UI-Pattern-Fixes (alle aus screenshot-User-Feedback 2026-05-07): - Snake-Bug (food-pellet React-18-StrictMode-Reducer-double-call) gefixt - Snake-Buttons platform-native (iOS-blue / Android-ripple) - Tetris-Margins (16px paddingHorizontal) - PostCard-Buttons Apple-44pt-Hit-Area (Image-Select, Image-Remove, Cancel, Share-Pill — via hitSlop) - ProfileHeader Demographics-Hint: alignSelf:'stretch' Pattern - ApprovedDomainsList Collapse: Title flex:1 + Chevron rechts - ProtectionDetailsSheet FAQ-Items: alignSelf:'stretch' defensive - AppHeader Back-Button: neue showBack-Prop + chevron-back Memory + Plan-Docs: - 17 Memory-Files dokumentieren System-Wissen + Patterns - ops/{CUTOVER, UI_MIGRATION, PROFILE_PAGE, WEBHOOK, GAMES_1V1, RELEASE_READINESS, TESTING_STATE, MAESTRO_HOSTING}_*.md Backend bleibt unverändert (Tier-LLM + Nickname + sort:latency sind seit gestern deployed). --- apps/rebreak-native/app/_layout.tsx | 32 + apps/rebreak-native/app/debug.tsx | 162 +++++ apps/rebreak-native/app/games.tsx | 240 +++++++ apps/rebreak-native/app/profile/[userId].tsx | 315 +++++++++ apps/rebreak-native/app/profile/index.tsx | 272 ++++++++ apps/rebreak-native/app/settings.tsx | 455 +++++++------ apps/rebreak-native/app/urge.tsx | 4 +- apps/rebreak-native/components/AppHeader.tsx | 183 +----- .../rebreak-native/components/ComposeCard.tsx | 18 +- .../blocker/ProtectionDetailsSheet.tsx | 6 +- .../components/games/GameCard.tsx | 77 +++ .../components/games/GameRatingStars.tsx | 34 + .../components/games/StarRating.tsx | 106 +++ .../components/header/HeaderDropdownMenu.tsx | 244 +++++++ .../profile/ApprovedDomainsList.tsx | 135 ++++ .../profile/DemographicsAccordion.tsx | 621 ++++++++++++++++++ .../components/profile/DigaMissionBanner.tsx | 113 ++++ .../components/profile/ProfileHeader.tsx | 241 +++++++ .../components/profile/StatsBar.tsx | 116 ++++ .../components/profile/StreakSection.tsx | 232 +++++++ .../components/profile/UrgeStatsCard.tsx | 209 ++++++ .../components/urge/UrgeGames.tsx | 137 ++-- apps/rebreak-native/lib/sosTtsBenchmark.ts | 13 +- apps/rebreak-native/lib/tabIcons.ts | 2 +- apps/rebreak-native/locales/de.json | 52 +- apps/rebreak-native/locales/en.json | 52 +- ops/GAMES_1V1_MIGRATION_PLAN.md | 259 ++++++++ ops/MAESTRO_HOSTING_DECISION.md | 223 +++++++ ops/PROFILE_PAGE_DESIGN.md | 417 ++++++++++++ ops/RELEASE_READINESS.md | 216 ++++++ ops/TESTING_STATE.md | 320 +++++++++ ops/UI_MIGRATION_PLAN.md | 265 ++++++++ ops/WEBHOOK_MIGRATION_PLAN.md | 177 +++++ 33 files changed, 5532 insertions(+), 416 deletions(-) create mode 100644 apps/rebreak-native/app/debug.tsx create mode 100644 apps/rebreak-native/app/games.tsx create mode 100644 apps/rebreak-native/app/profile/[userId].tsx create mode 100644 apps/rebreak-native/app/profile/index.tsx create mode 100644 apps/rebreak-native/components/games/GameCard.tsx create mode 100644 apps/rebreak-native/components/games/GameRatingStars.tsx create mode 100644 apps/rebreak-native/components/games/StarRating.tsx create mode 100644 apps/rebreak-native/components/header/HeaderDropdownMenu.tsx create mode 100644 apps/rebreak-native/components/profile/ApprovedDomainsList.tsx create mode 100644 apps/rebreak-native/components/profile/DemographicsAccordion.tsx create mode 100644 apps/rebreak-native/components/profile/DigaMissionBanner.tsx create mode 100644 apps/rebreak-native/components/profile/ProfileHeader.tsx create mode 100644 apps/rebreak-native/components/profile/StatsBar.tsx create mode 100644 apps/rebreak-native/components/profile/StreakSection.tsx create mode 100644 apps/rebreak-native/components/profile/UrgeStatsCard.tsx create mode 100644 ops/GAMES_1V1_MIGRATION_PLAN.md create mode 100644 ops/MAESTRO_HOSTING_DECISION.md create mode 100644 ops/PROFILE_PAGE_DESIGN.md create mode 100644 ops/RELEASE_READINESS.md create mode 100644 ops/TESTING_STATE.md create mode 100644 ops/UI_MIGRATION_PLAN.md create mode 100644 ops/WEBHOOK_MIGRATION_PLAN.md 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 `