feat(profile): Profile-Page komplett + Header-Dropdown + UI-Pattern-Fixes
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).
This commit is contained in:
parent
355166c194
commit
e76be7ee78
@ -106,6 +106,38 @@ function RootLayoutInner() {
|
|||||||
animation: 'slide_from_right',
|
animation: 'slide_from_right',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="profile/index"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="profile/[userId]"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="games"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="debug"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'card',
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
162
apps/rebreak-native/app/debug.tsx
Normal file
162
apps/rebreak-native/app/debug.tsx
Normal file
@ -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 <View style={{ flex: 1, backgroundColor: '#ffffff' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.back()}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
Debug
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#fef3c7',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#fde68a',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="warning-outline" size={20} color="#b45309" style={{ marginTop: 1 }} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
|
||||||
|
Dev only
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#92400e',
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diese Page ist nur in __DEV__ verfügbar. Production-Builds redirecten auf /.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DebugStub
|
||||||
|
title="LLM-Provider Toggle"
|
||||||
|
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
|
||||||
|
icon="bulb-outline"
|
||||||
|
/>
|
||||||
|
<DebugStub
|
||||||
|
title="TTS-Provider Toggle"
|
||||||
|
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
|
||||||
|
icon="volume-high-outline"
|
||||||
|
/>
|
||||||
|
<DebugStub
|
||||||
|
title="Bench-Output"
|
||||||
|
subtitle="TTS/LLM-Latenz-Logs anzeigen (TODO)"
|
||||||
|
icon="speedometer-outline"
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DebugStub({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0,0,0,0.05)',
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 11,
|
||||||
|
backgroundColor: '#e5e7eb',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={18} color="#525252" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 14, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>{title}</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#737373',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 3,
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
apps/rebreak-native/app/games.tsx
Normal file
240
apps/rebreak-native/app/games.tsx
Normal file
@ -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<GameType, GameStat>;
|
||||||
|
|
||||||
|
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<GameType | null>(null);
|
||||||
|
const [lastScore, setLastScore] = useState<LastScore>(null);
|
||||||
|
const [gameStats, setGameStats] = useState<GameStats>(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 (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 8,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => exit()}
|
||||||
|
hitSlop={10}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 6,
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
|
||||||
|
{t('games.back_to_picker')}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
|
||||||
|
{t(GAME_META.find((g) => g.id === active)!.titleKey)}
|
||||||
|
</Text>
|
||||||
|
<View style={{ width: 60 }} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
{active === 'memory' ? (
|
||||||
|
<MemoryGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
|
||||||
|
) : null}
|
||||||
|
{active === 'tictactoe' ? (
|
||||||
|
<TicTacToeGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
|
||||||
|
) : null}
|
||||||
|
{active === 'snake' ? (
|
||||||
|
<SnakeGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
|
||||||
|
) : null}
|
||||||
|
{active === 'tetris' ? (
|
||||||
|
<TetrisGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.back()}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('games.title')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#737373',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
lineHeight: 19,
|
||||||
|
marginBottom: 18,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('games.subtitle')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{GAME_META.map((game) => {
|
||||||
|
const stat = gameStats[game.id] ?? { avgStars: 0, count: 0 };
|
||||||
|
const recent = lastScore?.game === game.id ? lastScore.score : null;
|
||||||
|
return (
|
||||||
|
<View key={game.id} style={{ width: '47.5%' }}>
|
||||||
|
<GameCard
|
||||||
|
id={game.id}
|
||||||
|
svg={game.svg}
|
||||||
|
titleKey={game.titleKey}
|
||||||
|
descKey={game.descKey}
|
||||||
|
avgStars={stat.avgStars}
|
||||||
|
count={stat.count}
|
||||||
|
onPress={(id) => setActive(id)}
|
||||||
|
/>
|
||||||
|
{recent !== null ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: colors.brandOrange + '18',
|
||||||
|
alignSelf: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: colors.brandOrange,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('games.last_score', { score: recent })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#a3a3a3',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 24,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('games.skeleton_footer')}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
apps/rebreak-native/app/profile/[userId].tsx
Normal file
315
apps/rebreak-native/app/profile/[userId].tsx
Normal file
@ -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<Plan, string> = {
|
||||||
|
free: 'Free',
|
||||||
|
pro: 'Pro',
|
||||||
|
legend: 'Legend',
|
||||||
|
};
|
||||||
|
|
||||||
|
const planColors: Record<Plan, { bg: string; text: string; border: string }> = {
|
||||||
|
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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#ffffff' }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingTop: insets.top,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e5e5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => router.back()}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.5 : 1,
|
||||||
|
padding: 8,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<Text style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
Profil
|
||||||
|
</Text>
|
||||||
|
<View style={{ width: 38 }} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: 48,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: planStyle.border,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: showImage ? '#fafafa' : colors.brandOrange,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showImage ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: avatarUrl }}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
style={{ width: 92, height: 92, borderRadius: 46 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#fff', fontSize: 32, fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.nickname}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: planStyle.bg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: planStyle.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: planStyle.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{planLabel[profile.plan].toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
|
Mitglied seit {profile.memberSince}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, width: '100%' }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
// 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',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: isFollowing ? colors.text : '#ffffff',
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFollowing ? 'Folge ich' : 'Folgen'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
// 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',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Nachricht
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, paddingHorizontal: 16 }}>
|
||||||
|
<ForeignStat value={String(profile.postsCount)} label="Posts" />
|
||||||
|
<ForeignStat value={String(profile.followersCount)} label="Follower" />
|
||||||
|
<ForeignStat value={String(profile.approvedDomainsCount)} label="Approved" />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* TODO: GET /api/community/posts?userId=... — letzte 5 Posts */}
|
||||||
|
<View style={{ marginTop: 24, paddingHorizontal: 16 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
LETZTE POSTS
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Posts-Liste folgt in Phase C
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
apps/rebreak-native/app/profile/index.tsx
Normal file
272
apps/rebreak-native/app/profile/index.tsx
Normal file
@ -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<Demographics>(DUMMY_DEMOGRAPHICS);
|
||||||
|
const { me } = useMe();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
const demographicsAnchorRef = useRef<View | null>(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 (
|
||||||
|
<View style={{ flex: 1, backgroundColor: '#ffffff' }}>
|
||||||
|
<AppHeader showBack title="Profil" />
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<ProfileHeader
|
||||||
|
nickname={profile.nickname}
|
||||||
|
email={profile.email}
|
||||||
|
avatar={profile.avatar}
|
||||||
|
plan={profile.plan}
|
||||||
|
memberSince={profile.memberSince}
|
||||||
|
provider={profile.provider}
|
||||||
|
showDemographicsHint={!demoComplete}
|
||||||
|
onDemographicsHintPress={scrollToDemographics}
|
||||||
|
onEditAvatar={() => {
|
||||||
|
// TODO Phase C: AvatarPickerSheet (preset-grid + custom-upload via expo-image-picker)
|
||||||
|
Alert.alert(
|
||||||
|
'Avatar bearbeiten',
|
||||||
|
'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onEditNickname={() => {
|
||||||
|
// TODO Phase C: NicknameEditSheet → PATCH /api/auth/me
|
||||||
|
Alert.alert(
|
||||||
|
'Nickname bearbeiten',
|
||||||
|
'Inline-Edit + Save kommt in der nächsten Iteration.',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||||
|
marginHorizontal: 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={{ marginTop: 16 }}>
|
||||||
|
<StatsBar
|
||||||
|
postsCount={DUMMY_STATS.postsCount}
|
||||||
|
followersCount={DUMMY_STATS.followersCount}
|
||||||
|
approvedDomainsCount={DUMMY_STATS.approvedDomainsCount}
|
||||||
|
onPostsPress={() => {
|
||||||
|
// 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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
|
||||||
|
|
||||||
|
{showDigaBanner ? (
|
||||||
|
<DigaMissionBanner
|
||||||
|
onDismiss={() => {
|
||||||
|
// TODO: AsyncStorage persist `diga_banner_dismissed_at`
|
||||||
|
setBannerDismissed(true);
|
||||||
|
}}
|
||||||
|
onContribute={() => {
|
||||||
|
setBannerDismissed(true);
|
||||||
|
scrollToDemographics();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<StreakSection
|
||||||
|
currentDays={DUMMY_STREAK.currentDays}
|
||||||
|
longestDays={DUMMY_STREAK.longestDays}
|
||||||
|
startDate={DUMMY_STREAK.startDate}
|
||||||
|
cooldowns={DUMMY_COOLDOWNS}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UrgeStatsCard
|
||||||
|
sessions={5}
|
||||||
|
overcome={4}
|
||||||
|
helpedBy={DUMMY_HELPED_BY}
|
||||||
|
topEmotion="Stress"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Anchor: Hint-Tap im Header scrollt hierhin */}
|
||||||
|
<View ref={demographicsAnchorRef} collapsable={false}>
|
||||||
|
<DemographicsAccordion
|
||||||
|
demographics={demographics}
|
||||||
|
plan={profile.plan}
|
||||||
|
onChange={(next) => {
|
||||||
|
// 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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: 24 }} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
opacity: 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profil-Skeleton (dummy data) — Backend wired in Phase C
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
import { colors } from '../lib/theme';
|
||||||
|
|
||||||
type SettingRow = {
|
type SectionRow = {
|
||||||
label: string;
|
|
||||||
sublabel?: string;
|
|
||||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
||||||
iconColor: string;
|
iconColor: string;
|
||||||
onPress?: () => void;
|
label: string;
|
||||||
right?: React.ReactNode;
|
sublabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
rows: SectionRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, signOut } = useAuthStore();
|
|
||||||
const [notifPush, setNotifPush] = useState(true);
|
|
||||||
const [notifStreak, setNotifStreak] = useState(true);
|
|
||||||
|
|
||||||
const email = user?.email ?? '';
|
const sections: Section[] = [
|
||||||
const initials = email.slice(0, 2).toUpperCase();
|
|
||||||
|
|
||||||
async function handleSignOut() {
|
|
||||||
await signOut();
|
|
||||||
router.replace('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
const accountRows: SettingRow[] = [
|
|
||||||
{
|
{
|
||||||
label: t('settings.edit_profile'),
|
key: 'profile',
|
||||||
icon: 'pencil-outline',
|
title: t('settings.section_profile'),
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
icon: 'person-outline',
|
||||||
iconColor: '#6366f1',
|
iconColor: '#6366f1',
|
||||||
onPress: () => {},
|
label: t('settings.profile_edit'),
|
||||||
|
sublabel: t('settings.profile_edit_desc'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('settings.devices'),
|
icon: 'image-outline',
|
||||||
sublabel: t('settings.devices_desc'),
|
iconColor: '#6366f1',
|
||||||
icon: 'phone-portrait-outline',
|
label: t('settings.profile_avatar'),
|
||||||
iconColor: '#16a34a',
|
sublabel: t('settings.profile_avatar_desc'),
|
||||||
onPress: () => {},
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('settings.subscription'),
|
key: 'theme',
|
||||||
sublabel: t('settings.plan_free'),
|
title: t('settings.section_theme'),
|
||||||
icon: 'star-outline',
|
rows: [
|
||||||
iconColor: colors.brandOrange,
|
|
||||||
onPress: () => {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const prefRows: SettingRow[] = [
|
|
||||||
{
|
{
|
||||||
label: t('settings.push_notifications'),
|
icon: 'color-palette-outline',
|
||||||
icon: 'notifications-outline',
|
iconColor: '#a78bfa',
|
||||||
iconColor: '#2563eb',
|
label: t('settings.theme'),
|
||||||
right: (
|
sublabel: t('settings.theme_desc'),
|
||||||
<Switch
|
|
||||||
value={notifPush}
|
|
||||||
onValueChange={setNotifPush}
|
|
||||||
trackColor={{ false: '#e5e5e5', true: colors.brandOrange + '60' }}
|
|
||||||
thumbColor={notifPush ? colors.brandOrange : '#a3a3a3'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('settings.streak_reminders'),
|
|
||||||
icon: 'flame-outline',
|
|
||||||
iconColor: '#f97316',
|
|
||||||
right: (
|
|
||||||
<Switch
|
|
||||||
value={notifStreak}
|
|
||||||
onValueChange={setNotifStreak}
|
|
||||||
trackColor={{ false: '#e5e5e5', true: colors.brandOrange + '60' }}
|
|
||||||
thumbColor={notifStreak ? colors.brandOrange : '#a3a3a3'}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t('settings.language'),
|
|
||||||
sublabel: t('settings.language_current'),
|
|
||||||
icon: 'language-outline',
|
icon: 'language-outline',
|
||||||
iconColor: '#a78bfa',
|
iconColor: '#a78bfa',
|
||||||
onPress: () => {},
|
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'),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-neutral-50" edges={['top']}>
|
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
|
||||||
<View className="px-3 pt-1 pb-3 flex-row items-center gap-2">
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.replace('/(app)' as never)}
|
onPress={() => router.back()}
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
className="w-10 h-10 items-center justify-center"
|
style={({ pressed }) => ({
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
<Ionicons name="chevron-back" size={26} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text className="text-neutral-900 text-xl" style={{ fontFamily: 'Nunito_700Bold' }}>{t('settings.title')}</Text>
|
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{t('settings.title')}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
className="flex-1"
|
style={{ flex: 1 }}
|
||||||
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40, paddingTop: 4 }}
|
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{/* Account Card */}
|
<View
|
||||||
<Card className="mb-5">
|
style={{
|
||||||
<View className="flex-row items-center gap-3">
|
backgroundColor: '#fef3c7',
|
||||||
<View className="w-14 h-14 rounded-full bg-rebreak-500 items-center justify-center">
|
borderRadius: 14,
|
||||||
<Text className="text-white text-lg" style={{ fontFamily: 'Nunito_800ExtraBold' }}>{initials}</Text>
|
padding: 14,
|
||||||
</View>
|
marginBottom: 20,
|
||||||
<View className="flex-1">
|
borderWidth: 1,
|
||||||
<Text className="text-neutral-900 text-base" numberOfLines={1} style={{ fontFamily: 'Nunito_700Bold' }}>
|
borderColor: '#fde68a',
|
||||||
{email}
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="construct-outline" size={20} color="#b45309" style={{ marginTop: 1 }} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
|
||||||
|
{t('settings.coming_soon_title')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#92400e',
|
||||||
|
marginTop: 4,
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.coming_soon_desc')}
|
||||||
</Text>
|
</Text>
|
||||||
<View className="flex-row items-center gap-1.5 mt-1">
|
|
||||||
<View className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
|
||||||
<Text className="text-neutral-500 text-xs" style={{ fontFamily: 'Nunito_400Regular' }}>{t('settings.plan_free')}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
<View className="mt-4 pt-3 border-t border-neutral-100">
|
|
||||||
<Button variant="secondary" onPress={() => {}}>
|
|
||||||
{t('settings.upgrade_cta')}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Account Section */}
|
{sections.map((section) => (
|
||||||
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
|
<View key={section.key} style={{ marginBottom: 22 }}>
|
||||||
{t('settings.account_section')}
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#a3a3a3',
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 1,
|
||||||
|
marginBottom: 8,
|
||||||
|
marginLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Card className="mb-5 py-0 overflow-hidden">
|
<View
|
||||||
{accountRows.map((row, i) => (
|
style={{
|
||||||
<Pressable
|
backgroundColor: '#fafafa',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(0,0,0,0.05)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{section.rows.map((row, i) => (
|
||||||
|
<View
|
||||||
key={row.label}
|
key={row.label}
|
||||||
onPress={row.onPress}
|
style={{
|
||||||
className={`flex-row items-center gap-3 px-4 py-3.5 ${
|
flexDirection: 'row',
|
||||||
i < accountRows.length - 1 ? 'border-b border-neutral-100' : ''
|
alignItems: 'center',
|
||||||
}`}
|
gap: 12,
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.04)',
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className="w-8 h-8 rounded-xl items-center justify-center"
|
style={{
|
||||||
style={{ backgroundColor: row.iconColor + '18' }}
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: row.iconColor + '18',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name={row.icon} size={16} color={row.iconColor} />
|
<Ionicons name={row.icon} size={16} color={row.iconColor} />
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-1">
|
<View style={{ flex: 1 }}>
|
||||||
<Text className="text-neutral-800 text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{row.label}</Text>
|
<Text style={{ fontSize: 14, color: '#0a0a0a', fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
{row.sublabel ? (
|
{row.label}
|
||||||
<Text className="text-neutral-400 text-xs mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>{row.sublabel}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{row.right ?? (
|
|
||||||
<Ionicons name="chevron-forward" size={14} color="#a3a3a3" />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Preferences Section */}
|
|
||||||
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
|
|
||||||
{t('settings.prefs_section')}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Card className="mb-5 py-0 overflow-hidden">
|
<Text
|
||||||
{prefRows.map((row, i) => (
|
style={{
|
||||||
<View
|
fontSize: 12,
|
||||||
key={row.label}
|
color: '#737373',
|
||||||
className={`flex-row items-center gap-3 px-4 py-3.5 ${
|
fontFamily: 'Nunito_400Regular',
|
||||||
i < prefRows.length - 1 ? 'border-b border-neutral-100' : ''
|
marginTop: 2,
|
||||||
}`}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
{row.sublabel}
|
||||||
className="w-8 h-8 rounded-xl items-center justify-center"
|
</Text>
|
||||||
style={{ backgroundColor: row.iconColor + '18' }}
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#a3a3a3',
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name={row.icon} size={16} color={row.iconColor} />
|
{t('settings.soon_badge')}
|
||||||
</View>
|
</Text>
|
||||||
<View className="flex-1">
|
</View>
|
||||||
<Text className="text-neutral-800 text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{row.label}</Text>
|
))}
|
||||||
{row.sublabel ? (
|
</View>
|
||||||
<Text className="text-neutral-400 text-xs mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>{row.sublabel}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
{row.right ?? (
|
|
||||||
<Pressable onPress={row.onPress}>
|
|
||||||
<Ionicons name="chevron-forward" size={14} color="#a3a3a3" />
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Danger Zone */}
|
<Text
|
||||||
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
|
style={{
|
||||||
{t('settings.danger_section')}
|
textAlign: 'center',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#a3a3a3',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 6,
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.skeleton_footer')}
|
||||||
</Text>
|
</Text>
|
||||||
<Card className="mb-3">
|
<Text
|
||||||
<Button variant="danger" onPress={() => {}} className="mb-2">
|
style={{
|
||||||
{t('settings.delete_account')}
|
textAlign: 'center',
|
||||||
</Button>
|
fontSize: 10,
|
||||||
<Text className="text-neutral-400 text-xs text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
|
color: '#a3a3a3',
|
||||||
{t('settings.delete_desc')}
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
marginTop: 4,
|
||||||
|
opacity: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Platform.OS}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Button variant="secondary" onPress={handleSignOut}>
|
|
||||||
{t('settings.sign_out')}
|
|
||||||
</Button>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -350,7 +350,7 @@ export default function SOSScreen() {
|
|||||||
// Latenz-Benchmark — eine Session pro sendToLyra-Call. Marker werden in
|
// Latenz-Benchmark — eine Session pro sendToLyra-Call. Marker werden in
|
||||||
// stream/queue über onMetric gesammelt, gedruckt im onIdle (oder als
|
// stream/queue über onMetric gesammelt, gedruckt im onIdle (oder als
|
||||||
// Fallback im finally bei Errors / sound-off).
|
// 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 {
|
try {
|
||||||
const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content }));
|
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;
|
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
|
||||||
|
|
||||||
// Latenz-Benchmark fürs Greeting — gleiches Pattern wie sendToLyra.
|
// 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
|
// Hybrid-TTS-Queue, gleiches Pattern wie sendToLyra
|
||||||
const ttsQueue = soundEnabledRef.current
|
const ttsQueue = soundEnabledRef.current
|
||||||
|
|||||||
@ -1,28 +1,23 @@
|
|||||||
import { useState } from 'react';
|
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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useRouter, type RelativePathString } from 'expo-router';
|
import { useRouter, type RelativePathString } from 'expo-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useNotificationStore } from '../stores/notifications';
|
import { useNotificationStore } from '../stores/notifications';
|
||||||
import { supabase } from '../lib/supabase';
|
|
||||||
import { resolveAvatar } from '../lib/resolveAvatar';
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
||||||
import { useMe } from '../hooks/useMe';
|
import { useMe } from '../hooks/useMe';
|
||||||
import { NotificationsDropdown } from './NotificationsDropdown';
|
import { NotificationsDropdown } from './NotificationsDropdown';
|
||||||
|
import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
notifCount?: number;
|
notifCount?: number;
|
||||||
|
showBack?: boolean;
|
||||||
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MenuItem = {
|
export function AppHeader({ notifCount, showBack, title }: Props = {}) {
|
||||||
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
||||||
label: string;
|
|
||||||
color?: string;
|
|
||||||
action: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppHeader({ notifCount }: Props = {}) {
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -30,13 +25,12 @@ export function AppHeader({ notifCount }: Props = {}) {
|
|||||||
const { me } = useMe();
|
const { me } = useMe();
|
||||||
const storeUnread = useNotificationStore((s) => s.unread);
|
const storeUnread = useNotificationStore((s) => s.unread);
|
||||||
const badge = notifCount ?? storeUnread;
|
const badge = notifCount ?? storeUnread;
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [notifOpen, setNotifOpen] = useState(false);
|
const [notifOpen, setNotifOpen] = useState(false);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
const firstName = (user?.user_metadata?.first_name as string | undefined) ?? '';
|
const firstName = (user?.user_metadata?.first_name as string | undefined) ?? '';
|
||||||
const lastName = (user?.user_metadata?.last_name as string | undefined) ?? '';
|
const lastName = (user?.user_metadata?.last_name as string | undefined) ?? '';
|
||||||
const email = user?.email ?? '';
|
const email = user?.email ?? '';
|
||||||
// Initials-Fallback: erst nickname (DB), dann firstName/email
|
|
||||||
const initials = (() => {
|
const initials = (() => {
|
||||||
if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase();
|
if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase();
|
||||||
return ((firstName.charAt(0) + (lastName.charAt(0) || email.charAt(0))).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
|
// Avatar: aus DB (`/api/auth/me` → profiles.avatar). Kann Hero-Avatar-ID
|
||||||
// ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload)
|
// ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload)
|
||||||
// sein. resolveAvatar handlet beide Fälle.
|
// 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 avatarUrl = me ? resolveAvatar(me.avatar, me.nickname ?? '') : '';
|
||||||
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
|
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
|
||||||
const showAvatarImage = !!avatarUrl && !avatarLoadFailed && !!me?.avatar;
|
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;
|
const headerHeight = insets.top + 56;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -83,12 +51,24 @@ export function AppHeader({ notifCount }: Props = {}) {
|
|||||||
style={{ paddingTop: insets.top }}
|
style={{ paddingTop: insets.top }}
|
||||||
>
|
>
|
||||||
<View className="h-14 flex-row items-center justify-between px-5">
|
<View className="h-14 flex-row items-center justify-between px-5">
|
||||||
|
<View className="flex-row items-center" style={{ gap: 8 }}>
|
||||||
|
{showBack ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => 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"
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
|
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
|
||||||
{t('appHeader.appName')}
|
{title ?? t('appHeader.appName')}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-1">
|
||||||
{/* Notifications dropdown trigger */}
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setNotifOpen(true)}
|
onPress={() => 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"
|
||||||
@ -104,9 +84,9 @@ export function AppHeader({ notifCount }: Props = {}) {
|
|||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* Profil-Avatar — tap → dropdown */}
|
{/* Avatar = Trigger für Dropdown-Menu (kein separates 3-Punkte-Icon) */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => 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'}`}
|
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 })}
|
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
||||||
>
|
>
|
||||||
@ -120,113 +100,14 @@ export function AppHeader({ notifCount }: Props = {}) {
|
|||||||
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>{initials}</Text>
|
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>{initials}</Text>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Dropdown modal */}
|
<HeaderDropdownMenu
|
||||||
<Modal
|
visible={menuOpen}
|
||||||
visible={dropdownOpen}
|
onClose={() => setMenuOpen(false)}
|
||||||
transparent
|
topOffset={headerHeight + 6}
|
||||||
animationType="fade"
|
/>
|
||||||
statusBarTranslucent
|
|
||||||
onRequestClose={() => setDropdownOpen(false)}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => setDropdownOpen(false)}
|
|
||||||
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
onStartShouldSetResponder={() => true}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: headerHeight + 6,
|
|
||||||
right: 12,
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
borderRadius: 18,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowOpacity: 0.18,
|
|
||||||
shadowRadius: 20,
|
|
||||||
elevation: 12,
|
|
||||||
minWidth: 260,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* SOS prominent oben — Pressable mit innerem Row-View */}
|
|
||||||
<Pressable onPress={() => closeAndNavigate('/urge' as RelativePathString)}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
paddingVertical: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 18,
|
|
||||||
backgroundColor: '#fee2e2',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="heart" size={18} color="#dc2626" />
|
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
|
||||||
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>
|
|
||||||
{t('appHeader.sosLabel')}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}>
|
|
||||||
{t('appHeader.sosSubtitle')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
|
||||||
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<Pressable key={item.label} onPress={item.action}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
paddingVertical: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name={item.icon} size={18} color="#737373" style={{ marginRight: 14 }} />
|
|
||||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
|
||||||
|
|
||||||
<Pressable onPress={handleSignOut}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
paddingVertical: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Ionicons name="log-out-outline" size={18} color="#dc2626" style={{ marginRight: 14 }} />
|
|
||||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
|
|
||||||
{t('appHeader.signOut')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<NotificationsDropdown
|
<NotificationsDropdown
|
||||||
visible={notifOpen}
|
visible={notifOpen}
|
||||||
|
|||||||
@ -126,9 +126,13 @@ export function ComposeCard({ onPosted }: Props) {
|
|||||||
style={{ height: 160 }}
|
style={{ height: 160 }}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
|
{/* HitSlop +9pt rundum → 28pt visual + 18pt slop ≈ 46pt effektive Tap-Area (HIG ≥44pt). */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setImageUri(null)}
|
onPress={() => 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"
|
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 })}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={14} color="#fff" />
|
<Ionicons name="close" size={14} color="#fff" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@ -139,9 +143,12 @@ export function ComposeCard({ onPosted }: Props) {
|
|||||||
|
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<View className="flex-row items-center justify-between mt-3 pt-3 border-t border-neutral-100">
|
<View className="flex-row items-center justify-between mt-3 pt-3 border-t border-neutral-100">
|
||||||
|
{/* Image-Picker: visuell klein (icon 18pt + label), aber hitSlop +12 → effektive Tap-Area ~46pt (HIG-Min 44pt). */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={pickImage}
|
onPress={pickImage}
|
||||||
className="flex-row items-center gap-1.5"
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }}
|
||||||
|
className="flex-row items-center gap-1.5 py-1.5 pr-1.5"
|
||||||
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
|
||||||
>
|
>
|
||||||
<Ionicons name="image-outline" size={18} color="#737373" />
|
<Ionicons name="image-outline" size={18} color="#737373" />
|
||||||
@ -149,12 +156,19 @@ export function ComposeCard({ onPosted }: Props) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View className="flex-row items-center gap-2">
|
<View className="flex-row items-center gap-2">
|
||||||
<Pressable onPress={cancel}>
|
{/* Cancel-Label: hitSlop sichert ≥44pt Tap-Area trotz nackter Text-Höhe. */}
|
||||||
|
<Pressable
|
||||||
|
onPress={cancel}
|
||||||
|
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
<Text className="text-sm text-neutral-400" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('common.cancel')}</Text>
|
<Text className="text-sm text-neutral-400" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('common.cancel')}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{/* Share-Pill: visuell h-8 (32pt) bleibt erhalten — hitSlop +6 vertikal hebt Tap-Area auf 44pt. */}
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={submit}
|
onPress={submit}
|
||||||
disabled={!content.trim() || posting}
|
disabled={!content.trim() || posting}
|
||||||
|
hitSlop={{ top: 6, bottom: 6, left: 8, right: 8 }}
|
||||||
className="h-8 px-4 rounded-full bg-rebreak-500 items-center justify-center flex-row gap-1.5"
|
className="h-8 px-4 rounded-full bg-rebreak-500 items-center justify-center flex-row gap-1.5"
|
||||||
style={({ pressed }) => ({
|
style={({ pressed }) => ({
|
||||||
opacity: pressed || !content.trim() || posting ? 0.5 : 1,
|
opacity: pressed || !content.trim() || posting ? 0.5 : 1,
|
||||||
|
|||||||
@ -309,9 +309,10 @@ export function ProtectionDetailsSheet({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
|
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
|
||||||
<View style={{ gap: 8, marginTop: 4 }}>
|
<View style={{ alignSelf: 'stretch', gap: 8, marginTop: 4 }}>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
@ -344,6 +345,7 @@ export function ProtectionDetailsSheet({
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={onRequestDeactivation}
|
onPress={onRequestDeactivation}
|
||||||
style={({ pressed }) => ({
|
style={({ pressed }) => ({
|
||||||
|
alignSelf: 'stretch',
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
@ -657,6 +659,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
|||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
alignSelf: 'stretch',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '#e5e5e5',
|
borderColor: '#e5e5e5',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
@ -667,6 +670,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => setOpen((v) => !v)}
|
onPress={() => setOpen((v) => !v)}
|
||||||
style={({ pressed }) => ({
|
style={({ pressed }) => ({
|
||||||
|
alignSelf: 'stretch',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
|
|||||||
77
apps/rebreak-native/components/games/GameCard.tsx
Normal file
77
apps/rebreak-native/components/games/GameCard.tsx
Normal file
@ -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 (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => 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 }],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SvgXml xml={svg} width={56} height={56} />
|
||||||
|
<View style={{ alignItems: 'center', gap: 2 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
fontSize: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(titleKey)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#737373',
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 14,
|
||||||
|
minHeight: 28,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(descKey)}
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: 4 }}>
|
||||||
|
<GameRatingStars avg={avgStars} count={count} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/rebreak-native/components/games/GameRatingStars.tsx
Normal file
34
apps/rebreak-native/components/games/GameRatingStars.tsx
Normal file
@ -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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StarRating value={avg} size="xs" />
|
||||||
|
{count > 0 ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#737373',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
({count})
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/rebreak-native/components/games/StarRating.tsx
Normal file
106
apps/rebreak-native/components/games/StarRating.tsx
Normal file
@ -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<StarSize, number> = {
|
||||||
|
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 = (
|
||||||
|
<View
|
||||||
|
key={`star-${i}`}
|
||||||
|
style={{
|
||||||
|
width: px,
|
||||||
|
height: px,
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Empty star (background) */}
|
||||||
|
<Ionicons
|
||||||
|
name="star"
|
||||||
|
size={px}
|
||||||
|
color={emptyColor}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||||
|
/>
|
||||||
|
{/* Filled star clipped to filledWidth */}
|
||||||
|
{filledRatio > 0 ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: filledWidth,
|
||||||
|
height: px,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="star"
|
||||||
|
size={px}
|
||||||
|
color={filledColor}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
stars.push(
|
||||||
|
<Pressable
|
||||||
|
key={`press-${i}`}
|
||||||
|
onPress={() => onChange?.(i)}
|
||||||
|
onHoverIn={() => setHover(i)}
|
||||||
|
onHoverOut={() => setHover(0)}
|
||||||
|
hitSlop={4}
|
||||||
|
>
|
||||||
|
{star}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stars.push(star);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2 }}>
|
||||||
|
{stars}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
apps/rebreak-native/components/header/HeaderDropdownMenu.tsx
Normal file
244
apps/rebreak-native/components/header/HeaderDropdownMenu.tsx
Normal file
@ -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<typeof Ionicons>['name'];
|
||||||
|
onSelect: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
statusBarTranslucent
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
onStartShouldSetResponder={() => 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 */}
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
router.push('/urge' as RelativePathString);
|
||||||
|
}}
|
||||||
|
android_ripple={{ color: '#fee2e2' }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#fee2e2',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart" size={18} color="#dc2626" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
color: '#dc2626',
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t('appHeader.sosLabel')}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
color: '#a3a3a3',
|
||||||
|
marginTop: 1,
|
||||||
|
}}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{t('appHeader.sosTagline')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
||||||
|
|
||||||
|
{/* Profile · Settings · Games · [Debug DEV] */}
|
||||||
|
{items.map((item) => (
|
||||||
|
<Pressable
|
||||||
|
key={item.key}
|
||||||
|
onPress={() => {
|
||||||
|
onClose();
|
||||||
|
void item.onSelect();
|
||||||
|
}}
|
||||||
|
android_ripple={{ color: '#e5e7eb' }}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={item.icon}
|
||||||
|
size={18}
|
||||||
|
color="#737373"
|
||||||
|
style={{ marginRight: 14 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
||||||
|
|
||||||
|
{/* Abmelden — neutral, nicht rot */}
|
||||||
|
<Pressable
|
||||||
|
onPress={handleLogout}
|
||||||
|
android_ripple={{ color: '#e5e7eb' }}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
paddingVertical: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="log-out-outline"
|
||||||
|
size={18}
|
||||||
|
color="#737373"
|
||||||
|
style={{ marginRight: 14 }}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
color: '#0a0a0a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('headerMenu.logout')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
apps/rebreak-native/components/profile/ApprovedDomainsList.tsx
Normal file
135
apps/rebreak-native/components/profile/ApprovedDomainsList.tsx
Normal file
@ -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 (
|
||||||
|
<View style={{ marginHorizontal: 16, marginTop: 12 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={toggle}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 12,
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="shield-checkmark-outline"
|
||||||
|
size={16}
|
||||||
|
color={colors.textMuted}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
<Text style={{ flex: 1, fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
Approved Domains{' '}
|
||||||
|
<Text style={{ color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
|
({domains.length})
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={16}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<View style={{ padding: 16 }}>
|
||||||
|
<SkeletonRow />
|
||||||
|
<SkeletonRow />
|
||||||
|
<SkeletonRow />
|
||||||
|
</View>
|
||||||
|
) : domains.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Noch keine approved Domains. Submit deine erste in der Community.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
domains.map((d, idx) => (
|
||||||
|
<View
|
||||||
|
key={d.domain}
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderTopWidth: idx === 0 ? 0 : 1,
|
||||||
|
borderTopColor: 'rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{d.domain}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
|
||||||
|
>
|
||||||
|
{d.approvedAt}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonRow() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 14,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginVertical: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
621
apps/rebreak-native/components/profile/DemographicsAccordion.tsx
Normal file
621
apps/rebreak-native/components/profile/DemographicsAccordion.tsx
Normal file
@ -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<keyof Demographics, string> = {
|
||||||
|
birthYear:
|
||||||
|
'Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe.',
|
||||||
|
gender: 'Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel.',
|
||||||
|
profession:
|
||||||
|
'Schichtarbeit, Banking-Stress, Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext.',
|
||||||
|
maritalStatus:
|
||||||
|
'Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir.',
|
||||||
|
bundesland: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
|
||||||
|
city: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
|
||||||
|
};
|
||||||
|
|
||||||
|
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>(demographics);
|
||||||
|
|
||||||
|
// Select-Sheet-State
|
||||||
|
const [pickerField, setPickerField] = useState<keyof Demographics | null>(null);
|
||||||
|
|
||||||
|
// Debounce-Save Ref
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
|
||||||
|
{/* Privacy-Header */}
|
||||||
|
<Pressable
|
||||||
|
onPress={toggle}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ANONYMER BEITRAG ZUR FORSCHUNG
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Optional. Niemals mit Name oder Email verknüpft. Jederzeit löschbar.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name={expanded ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={18}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 14,
|
||||||
|
paddingVertical: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Pro-Trial-Reward-Banner — nur free + (idealerweise) nicht-vollständig.
|
||||||
|
Wir zeigen ihn aber auch im "completed"-State als sanfte Bestätigung,
|
||||||
|
tatsächliche Trial-Vergabe ist Backend-Sache (Phase C). */}
|
||||||
|
{plan === 'free' ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginHorizontal: 8,
|
||||||
|
marginVertical: 8,
|
||||||
|
backgroundColor: '#fff7ed',
|
||||||
|
borderColor: '#fed7aa',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="gift-outline" size={18} color="#c2410c" style={{ marginTop: 1 }} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#7c2d12',
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showProTrialBanner
|
||||||
|
? 'Du bekommst 1 Woche Pro geschenkt'
|
||||||
|
: 'Vervollständige dein Profil — 1 Woche Pro geschenkt'}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
color: '#9a3412',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
lineHeight: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mit deinen anonymen Daten machen wir rebreak zur ersten DiGA-zertifizierten
|
||||||
|
Spielsucht-App. Als Dankeschön: 1 Woche Pro.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Birth Year — Number-Input */}
|
||||||
|
<FieldRow
|
||||||
|
label="Geburtsjahr"
|
||||||
|
why={FIELD_WHY.birthYear}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
value={local.birthYear !== null ? String(local.birthYear) : ''}
|
||||||
|
onChangeText={(raw) => {
|
||||||
|
const cleaned = raw.replace(/[^0-9]/g, '').slice(0, 4);
|
||||||
|
if (cleaned === '') {
|
||||||
|
persist({ ...local, birthYear: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(cleaned, 10);
|
||||||
|
// Erlaube tippen — Validierung beim Blur
|
||||||
|
persist({ ...local, birthYear: Number.isNaN(n) ? null : n });
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = local.birthYear;
|
||||||
|
if (n !== null && (n < 1920 || n > 2010)) {
|
||||||
|
// ungültig — auf null zurücksetzen
|
||||||
|
flushSave({ ...local, birthYear: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
maxLength={4}
|
||||||
|
placeholder="z.B. 1989"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
{/* Gender — Select */}
|
||||||
|
<FieldRow label="Geschlecht" why={FIELD_WHY.gender}>
|
||||||
|
<SelectButton
|
||||||
|
value={lookupLabel(GENDER_OPTIONS, local.gender)}
|
||||||
|
onPress={() => setPickerField('gender')}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
{/* Profession — TextInput */}
|
||||||
|
<FieldRow label="Beruf" why={FIELD_WHY.profession}>
|
||||||
|
<TextInput
|
||||||
|
value={local.profession ?? ''}
|
||||||
|
onChangeText={(t) => persist({ ...local, profession: t })}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = (local.profession ?? '').trim();
|
||||||
|
flushSave({ ...local, profession: trimmed === '' ? null : trimmed });
|
||||||
|
}}
|
||||||
|
maxLength={80}
|
||||||
|
placeholder="z.B. Pflege, IT, Schichtarbeit"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
{/* Marital — Select */}
|
||||||
|
<FieldRow label="Familienstand" why={FIELD_WHY.maritalStatus}>
|
||||||
|
<SelectButton
|
||||||
|
value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)}
|
||||||
|
onPress={() => setPickerField('maritalStatus')}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
{/* Bundesland — Select */}
|
||||||
|
<FieldRow label="Bundesland" why={FIELD_WHY.bundesland}>
|
||||||
|
<SelectButton
|
||||||
|
value={lookupLabel(BUNDESLAND_OPTIONS, local.bundesland)}
|
||||||
|
onPress={() => setPickerField('bundesland')}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
{/* City — TextInput */}
|
||||||
|
<FieldRow label="Stadt" why={FIELD_WHY.city} isLast>
|
||||||
|
<TextInput
|
||||||
|
value={local.city ?? ''}
|
||||||
|
onChangeText={(t) => persist({ ...local, city: t })}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = (local.city ?? '').trim();
|
||||||
|
flushSave({ ...local, city: trimmed === '' ? null : trimmed });
|
||||||
|
}}
|
||||||
|
maxLength={60}
|
||||||
|
placeholder="z.B. München"
|
||||||
|
placeholderTextColor={colors.textMuted}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
|
||||||
|
{/* Revoke Consent */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onRevokeConsent}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
marginTop: 4,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(0,0,0,0.06)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.error,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Einwilligung widerrufen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SelectSheet
|
||||||
|
visible={pickerField === 'gender'}
|
||||||
|
title="Geschlecht"
|
||||||
|
options={GENDER_OPTIONS}
|
||||||
|
selectedValue={local.gender}
|
||||||
|
onClose={() => setPickerField(null)}
|
||||||
|
onSelect={(v) => {
|
||||||
|
flushSave({ ...local, gender: v });
|
||||||
|
setPickerField(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SelectSheet
|
||||||
|
visible={pickerField === 'maritalStatus'}
|
||||||
|
title="Familienstand"
|
||||||
|
options={MARITAL_OPTIONS}
|
||||||
|
selectedValue={local.maritalStatus}
|
||||||
|
onClose={() => setPickerField(null)}
|
||||||
|
onSelect={(v) => {
|
||||||
|
flushSave({ ...local, maritalStatus: v });
|
||||||
|
setPickerField(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SelectSheet
|
||||||
|
visible={pickerField === 'bundesland'}
|
||||||
|
title="Bundesland"
|
||||||
|
options={BUNDESLAND_OPTIONS}
|
||||||
|
selectedValue={local.bundesland}
|
||||||
|
onClose={() => setPickerField(null)}
|
||||||
|
onSelect={(v) => {
|
||||||
|
flushSave({ ...local, bundesland: v });
|
||||||
|
setPickerField(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderBottomWidth: isLast ? 0 : 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexShrink: 1 }}>{children}</View>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
lineHeight: 15,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{why}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectButton({ value, onPress }: { value: string | null; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ececec',
|
||||||
|
minWidth: 140,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: value ? colors.text : colors.textMuted,
|
||||||
|
fontFamily: value ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
|
||||||
|
textAlign: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value ?? 'auswählen'}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={14} color={colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSheet({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
selectedValue,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
options: Array<{ label: string; value: string }>;
|
||||||
|
selectedValue: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const sortedOptions = useMemo(() => options, [options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={onClose}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
/* swallow */
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderTopLeftRadius: 18,
|
||||||
|
borderTopRightRadius: 18,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 24,
|
||||||
|
maxHeight: '70%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 15,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Pressable onPress={onClose} hitSlop={10}>
|
||||||
|
<Ionicons name="close" size={22} color={colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<ScrollView style={{ maxHeight: 380 }}>
|
||||||
|
{sortedOptions.map((opt) => {
|
||||||
|
const isSelected = opt.value === selectedValue;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={opt.value}
|
||||||
|
onPress={() => onSelect(opt.value)}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: isSelected ? '#f5f8ff' : 'transparent',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
{isSelected ? (
|
||||||
|
<Ionicons name="checkmark" size={18} color={colors.brandOrange} />
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/rebreak-native/components/profile/DigaMissionBanner.tsx
Normal file
113
apps/rebreak-native/components/profile/DigaMissionBanner.tsx
Normal file
@ -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 (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
backgroundColor: '#fffbeb',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#fde68a',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#fef3c7',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="medkit-outline" size={14} color="#854d0e" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#854d0e',
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
30 Tage geschützt — danke
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#92400e',
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
|
||||||
|
<Pressable
|
||||||
|
onPress={onContribute}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
backgroundColor: '#854d0e',
|
||||||
|
borderRadius: 8,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Beitragen
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onDismiss}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
opacity: pressed ? 0.7 : 1,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
paddingVertical: 7,
|
||||||
|
borderRadius: 8,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#92400e',
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Später
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
onPress={onDismiss}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={16} color={colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
apps/rebreak-native/components/profile/ProfileHeader.tsx
Normal file
241
apps/rebreak-native/components/profile/ProfileHeader.tsx
Normal file
@ -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<Plan, string> = {
|
||||||
|
free: 'Free',
|
||||||
|
pro: 'Pro',
|
||||||
|
legend: 'Legend',
|
||||||
|
};
|
||||||
|
|
||||||
|
const planColors: Record<Plan, { bg: string; text: string; border: string }> = {
|
||||||
|
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 (
|
||||||
|
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
|
||||||
|
{/* Avatar — Pressable; Camera-Badge ist eigene Pressable (vorher nur dekoratives View) */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onEditAvatar}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
position: 'relative',
|
||||||
|
opacity: pressed ? 0.85 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
borderRadius: 48,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: planStyle.border,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: showImage ? '#fafafa' : colors.brandOrange,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showImage ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: avatarUrl }}
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
style={{ width: 92, height: 92, borderRadius: 46 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={{ color: '#fff', fontSize: 32, fontFamily: 'Nunito_700Bold' }}>
|
||||||
|
{initials}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Camera-Badge — iOS-Photos-Pattern: blauer Kreis, weißes Icon */}
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -2,
|
||||||
|
bottom: -2,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 15,
|
||||||
|
backgroundColor: '#0a0a0a',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#ffffff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="camera" size={14} color="#ffffff" />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Nickname — ganze Zeile Pressable (iOS-Settings-Pattern), kein hässliches Pencil */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onEditNickname}
|
||||||
|
hitSlop={8}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
gap: 6,
|
||||||
|
opacity: pressed ? 0.5 : 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{nickname}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{providerPillLabel ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={provider === 'apple' ? 'logo-apple' : 'logo-google'}
|
||||||
|
size={11}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
||||||
|
{providerPillLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{email}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: planStyle.bg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: planStyle.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: planStyle.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{planLabel[plan].toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
||||||
|
Mitglied seit {memberSince}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Freundlicher Hint statt Progress-Bar — nur sichtbar wenn Demographics unvollständig */}
|
||||||
|
{showDemographicsHint ? (
|
||||||
|
<Pressable
|
||||||
|
onPress={onDemographicsHintPress}
|
||||||
|
hitSlop={6}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
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,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Ionicons name="heart-outline" size={16} color={colors.brandOrange} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
lineHeight: 17,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hilf uns rebreak besser zu machen — fülle deine anonymen Daten aus.
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
apps/rebreak-native/components/profile/StatsBar.tsx
Normal file
116
apps/rebreak-native/components/profile/StatsBar.tsx
Normal file
@ -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<typeof Ionicons>['name'];
|
||||||
|
onPress?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatCard({ value, label, icon, onPress }: CardProps) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
style={({ pressed }) => ({
|
||||||
|
flex: 1,
|
||||||
|
opacity: pressed ? 0.6 : 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 30,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
{icon ? (
|
||||||
|
<Ionicons name={icon} size={16} color={colors.textMuted} style={{ marginTop: 4 }} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.08)',
|
||||||
|
marginVertical: 14,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Community-Stats: 3 prominente Cards in einer zentrierten Reihe.
|
||||||
|
* - Posts / Follower / Approved Domains
|
||||||
|
* - Approved Domains: PLAIN INTEGER (kein Cap), Trophy-Icon als Community-Beitrag-Hint
|
||||||
|
*/
|
||||||
|
export function StatsBar({
|
||||||
|
postsCount,
|
||||||
|
followersCount,
|
||||||
|
approvedDomainsCount,
|
||||||
|
onPostsPress,
|
||||||
|
onFollowersPress,
|
||||||
|
onApprovedDomainsPress,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<View style={{ paddingHorizontal: 16, alignItems: 'center' }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
borderRadius: 18,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ececec',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 420,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard value={String(postsCount)} label="Posts" onPress={onPostsPress} />
|
||||||
|
<Divider />
|
||||||
|
<StatCard value={String(followersCount)} label="Follower" onPress={onFollowersPress} />
|
||||||
|
<Divider />
|
||||||
|
<StatCard
|
||||||
|
value={String(approvedDomainsCount)}
|
||||||
|
label="Approved Domains"
|
||||||
|
icon="trophy-outline"
|
||||||
|
onPress={onApprovedDomainsPress}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
apps/rebreak-native/components/profile/StreakSection.tsx
Normal file
232
apps/rebreak-native/components/profile/StreakSection.tsx
Normal file
@ -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<CooldownEntry['status'], string> = {
|
||||||
|
active: 'aktiv',
|
||||||
|
resolved: 'beendet',
|
||||||
|
cancelled: 'abgebrochen',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor: Record<CooldownEntry['status'], { bg: string; text: string }> = {
|
||||||
|
active: { bg: '#fff7ed', text: '#c2410c' },
|
||||||
|
resolved: { bg: '#f0fdf4', text: '#15803d' },
|
||||||
|
cancelled: { bg: '#f5f5f5', text: '#737373' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) {
|
||||||
|
return (
|
||||||
|
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="flame-outline" size={14} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
STREAK
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_800ExtraBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentDays}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tage geschützt
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
seit {startDate}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Längste Streak: {longestDays} Tage
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{cooldowns.length > 0 ? (
|
||||||
|
<View style={{ marginTop: 16 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
marginBottom: 8,
|
||||||
|
paddingHorizontal: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
COOLDOWN-VERLAUF
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cooldowns.map((c, idx) => {
|
||||||
|
const isLast = idx === cooldowns.length - 1;
|
||||||
|
const colorPair = statusColor[c.status];
|
||||||
|
return (
|
||||||
|
<View key={c.id} style={{ flexDirection: 'row' }}>
|
||||||
|
<View style={{ width: 16, alignItems: 'center' }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor:
|
||||||
|
c.status === 'active'
|
||||||
|
? colors.brandOrange
|
||||||
|
: c.status === 'resolved'
|
||||||
|
? '#15803d'
|
||||||
|
: '#a3a3a3',
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!isLast ? (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ flex: 1, paddingLeft: 12, paddingBottom: isLast ? 0 : 14 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.startedAt}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.durationLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 999,
|
||||||
|
backgroundColor: colorPair.bg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
color: colorPair.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusLabel[c.status].toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{c.reason ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.reason}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
apps/rebreak-native/components/profile/UrgeStatsCard.tsx
Normal file
209
apps/rebreak-native/components/profile/UrgeStatsCard.tsx
Normal file
@ -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 (
|
||||||
|
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name="sparkles-outline" size={14} color={colors.textMuted} />
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
LYRA INSIGHTS
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e5e5',
|
||||||
|
borderRadius: 14,
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Letzte 30 Tage
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{sessions === 0 ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Noch keine SOS-Session. Lyra ist da, wenn du sie brauchst.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View style={{ marginTop: 6, flexDirection: 'row', alignItems: 'baseline', gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_700Bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sessions}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
SOS-Sessions, {overcome} bewältigt
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
height: 6,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${overcomePct}%`,
|
||||||
|
backgroundColor: colors.success,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 4,
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overcomePct}% bewältigt
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{totalHelped > 0 ? (
|
||||||
|
<View style={{ marginTop: 16 }}>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Was hat geholfen
|
||||||
|
</Text>
|
||||||
|
{helpedBy.map((h) => {
|
||||||
|
const pct = totalHelped > 0 ? (h.count / totalHelped) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<View key={h.key} style={{ marginBottom: 8 }}>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.text,
|
||||||
|
fontFamily: 'Nunito_600SemiBold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h.count} {h.count === 1 ? 'Session' : 'Sessions'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor: colors.brandOrange,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{topEmotion ? (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontFamily: 'Nunito_400Regular',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Häufigste Emotion: {topEmotion}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -121,13 +121,16 @@ export function SnakeGame({
|
|||||||
{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 },
|
{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 },
|
||||||
]);
|
]);
|
||||||
const [food, setFood] = useState<Pos>({ row: 3, col: 10 });
|
const [food, setFood] = useState<Pos>({ row: 3, col: 10 });
|
||||||
|
const snakeRef = useRef<Pos[]>(snake);
|
||||||
|
const foodRef = useRef<Pos>(food);
|
||||||
|
useEffect(() => { snakeRef.current = snake; }, [snake]);
|
||||||
|
useEffect(() => { foodRef.current = food; }, [food]);
|
||||||
const dirRef = useRef<Dir>('right');
|
const dirRef = useRef<Dir>('right');
|
||||||
const nextDirRef = useRef<Dir>('right');
|
const nextDirRef = useRef<Dir>('right');
|
||||||
const [score, setScore] = useState(0);
|
const [score, setScore] = useState(0);
|
||||||
const [highScore, setHighScore] = useState(0);
|
const [highScore, setHighScore] = useState(0);
|
||||||
const [gameOver, setGameOver] = useState(false);
|
const [gameOver, setGameOver] = useState(false);
|
||||||
const [activeDPad, setActiveDPad] = useState<Dir>('right');
|
const [activeDPad, setActiveDPad] = useState<Dir>('right');
|
||||||
const [, forceRender] = useState(0);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// Load high score
|
// Load high score
|
||||||
@ -164,43 +167,47 @@ export function SnakeGame({
|
|||||||
setTimeout(() => onComplete(finalScore), 500);
|
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(() => {
|
useEffect(() => {
|
||||||
if (gameOver) return;
|
if (gameOver) return;
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
dirRef.current = nextDirRef.current;
|
dirRef.current = nextDirRef.current;
|
||||||
setSnake((prev) => {
|
const prev = snakeRef.current;
|
||||||
const head = prev[0];
|
const head = prev[0];
|
||||||
if (!head) return prev;
|
if (!head) return;
|
||||||
const next: Pos = { row: head.row, col: head.col };
|
const next: Pos = { row: head.row, col: head.col };
|
||||||
if (dirRef.current === 'up') next.row--;
|
if (dirRef.current === 'up') next.row--;
|
||||||
else if (dirRef.current === 'down') next.row++;
|
else if (dirRef.current === 'down') next.row++;
|
||||||
else if (dirRef.current === 'left') next.col--;
|
else if (dirRef.current === 'left') next.col--;
|
||||||
else if (dirRef.current === 'right') next.col++;
|
else if (dirRef.current === 'right') next.col++;
|
||||||
if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) {
|
if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) {
|
||||||
setTimeout(() => endGame(score), 0);
|
endGame(score);
|
||||||
return prev;
|
return;
|
||||||
}
|
}
|
||||||
if (prev.some((s) => s.row === next.row && s.col === next.col)) {
|
if (prev.some((s) => s.row === next.row && s.col === next.col)) {
|
||||||
setTimeout(() => endGame(score), 0);
|
endGame(score);
|
||||||
return prev;
|
return;
|
||||||
}
|
}
|
||||||
const ate = next.row === food.row && next.col === food.col;
|
const currentFood = foodRef.current;
|
||||||
|
const ate = next.row === currentFood.row && next.col === currentFood.col;
|
||||||
const newSnake = [next, ...prev];
|
const newSnake = [next, ...prev];
|
||||||
if (!ate) newSnake.pop();
|
if (!ate) newSnake.pop();
|
||||||
else {
|
snakeRef.current = newSnake;
|
||||||
|
setSnake(newSnake);
|
||||||
|
if (ate) {
|
||||||
|
const newFood = randomFood(newSnake);
|
||||||
|
foodRef.current = newFood;
|
||||||
|
setFood(newFood);
|
||||||
setScore((s) => s + 1);
|
setScore((s) => s + 1);
|
||||||
setFood(randomFood(newSnake));
|
|
||||||
}
|
}
|
||||||
return newSnake;
|
|
||||||
});
|
|
||||||
forceRender((x) => x + 1);
|
|
||||||
}, SNAKE_TICK_MS);
|
}, SNAKE_TICK_MS);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [gameOver, food, score, highScore]);
|
}, [gameOver, score, highScore]);
|
||||||
|
|
||||||
// Swipe gestures
|
// Swipe gestures
|
||||||
const panResponder = useMemo(
|
const panResponder = useMemo(
|
||||||
@ -269,9 +276,9 @@ export function SnakeGame({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ paddingHorizontal: 8, paddingBottom: Math.max(insets.bottom, 16) }}>
|
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 8 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 14 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 14 }}>
|
||||||
<View style={{ alignItems: 'center' }}>
|
<View style={{ alignItems: 'center' }}>
|
||||||
@ -322,8 +329,8 @@ export function SnakeGame({
|
|||||||
<DPadBtn dir="up" active={activeDPad === 'up'} onPress={() => onDPad('up')} />
|
<DPadBtn dir="up" active={activeDPad === 'up'} onPress={() => onDPad('up')} />
|
||||||
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
|
||||||
<DPadBtn dir="left" active={activeDPad === 'left'} onPress={() => onDPad('left')} />
|
<DPadBtn dir="left" active={activeDPad === 'left'} onPress={() => onDPad('left')} />
|
||||||
<View style={{ width: 64, height: 64, borderRadius: 32, backgroundColor: '#f3f4f6', alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ width: 60, height: 60, borderRadius: 30, backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<View style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: '#d1d5db' }} />
|
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#d1d5db' }} />
|
||||||
</View>
|
</View>
|
||||||
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
|
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
|
||||||
</View>
|
</View>
|
||||||
@ -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 }) {
|
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
|
||||||
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
|
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
|
||||||
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
|
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.
|
const isIOS = Platform.OS === 'ios';
|
||||||
// FIX 2 (this agent): idle button was #ffffff on a #ffffff screen → invisible. Idle is now light-gray
|
const tint = '#007aff';
|
||||||
// with stronger border, pressed becomes mid-gray, active stays dark. Guarantees ≥ 3:1 contrast in all states.
|
|
||||||
const isHighlighted = active;
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => { tapHaptic(); onPress(); }}
|
onPress={() => { tapHaptic(); onPress(); }}
|
||||||
hitSlop={12}
|
hitSlop={12}
|
||||||
android_ripple={{ color: 'rgba(31,41,55,0.18)', borderless: true, radius: 36 }}
|
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
|
||||||
style={({ pressed }) => ({
|
style={({ pressed }) => {
|
||||||
width: 64, height: 64, borderRadius: 32,
|
const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff';
|
||||||
backgroundColor: isHighlighted ? '#1f2937' : (pressed ? '#d1d5db' : '#f3f4f6'),
|
const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5';
|
||||||
borderWidth: 1.5,
|
const bgActive = tint;
|
||||||
borderColor: isHighlighted ? '#1f2937' : (pressed ? '#6b7280' : '#9ca3af'),
|
const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle);
|
||||||
|
return {
|
||||||
|
width: 60, height: 60, borderRadius: 30,
|
||||||
|
backgroundColor: bg,
|
||||||
alignItems: 'center', justifyContent: 'center',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
...(isIOS ? {} : {
|
||||||
|
elevation: active ? 4 : 2,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: pressed ? 0.06 : 0.12,
|
shadowOpacity: 0.15,
|
||||||
shadowRadius: 4,
|
shadowRadius: 2,
|
||||||
elevation: pressed ? 1 : 3,
|
}),
|
||||||
transform: [{ scale: pressed ? 0.94 : 1 }],
|
transform: [{ scale: pressed && isIOS ? 0.96 : 1 }],
|
||||||
})}
|
};
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{({ pressed }) => (
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={icons[dir]}
|
name={icons[dir]}
|
||||||
size={30}
|
size={28}
|
||||||
color={isHighlighted ? '#ffffff' : (pressed ? '#111827' : '#1f2937')}
|
color={active ? '#ffffff' : tint}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -952,9 +963,9 @@ export function TetrisGame({
|
|||||||
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
|
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ paddingHorizontal: 8, paddingBottom: Math.max(insets.bottom, 16) }}>
|
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 8 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||||
<Stat label="Score" value={score} color="#111827" />
|
<Stat label="Score" value={score} color="#111827" />
|
||||||
@ -966,7 +977,7 @@ export function TetrisGame({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Board */}
|
{/* Board */}
|
||||||
<View style={{ alignItems: 'center' }}>
|
<View style={{ alignItems: 'center', marginVertical: 4 }}>
|
||||||
<View style={{ width: TETRIS_COLS * CELL, height: TETRIS_ROWS * CELL, borderRadius: 12, overflow: 'hidden', backgroundColor: '#0d1117', borderWidth: 1, borderColor: '#1f2937' }}>
|
<View style={{ width: TETRIS_COLS * CELL, height: TETRIS_ROWS * CELL, borderRadius: 12, overflow: 'hidden', backgroundColor: '#0d1117', borderWidth: 1, borderColor: '#1f2937' }}>
|
||||||
{displayBoard.map((row, y) => (
|
{displayBoard.map((row, y) => (
|
||||||
<View key={y} style={{ flexDirection: 'row', height: CELL }}>
|
<View key={y} style={{ flexDirection: 'row', height: CELL }}>
|
||||||
@ -993,7 +1004,7 @@ export function TetrisGame({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */}
|
{/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */}
|
||||||
<View style={{ marginTop: 14, paddingHorizontal: 16 }}>
|
<View style={{ marginTop: 14 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
<Ionicons name="flash" size={14} color={speedColors[speedLevel - 1]} />
|
<Ionicons name="flash" size={14} color={speedColors[speedLevel - 1]} />
|
||||||
@ -1023,7 +1034,7 @@ export function TetrisGame({
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Controls — Move Pad (links) + Action Pad (rechts) */}
|
{/* Controls — Move Pad (links) + Action Pad (rechts) */}
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, marginTop: 18 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 18 }}>
|
||||||
{/* Move Pad */}
|
{/* Move Pad */}
|
||||||
<View style={{ flexDirection: 'row', gap: 14 }}>
|
<View style={{ flexDirection: 'row', gap: 14 }}>
|
||||||
<DPadBtn dir="left" active={false} onPress={moveLeft} />
|
<DPadBtn dir="left" active={false} onPress={moveLeft} />
|
||||||
|
|||||||
@ -44,13 +44,15 @@ type MarkerEntry = {
|
|||||||
export class BenchSession {
|
export class BenchSession {
|
||||||
readonly t0: number;
|
readonly t0: number;
|
||||||
readonly provider: string;
|
readonly provider: string;
|
||||||
|
readonly llm: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
private entries: MarkerEntry[] = [];
|
private entries: MarkerEntry[] = [];
|
||||||
private printed = false;
|
private printed = false;
|
||||||
|
|
||||||
constructor(opts: { provider: string; label?: string }) {
|
constructor(opts: { provider: string; llm?: string; label?: string }) {
|
||||||
this.t0 = Date.now();
|
this.t0 = Date.now();
|
||||||
this.provider = opts.provider;
|
this.provider = opts.provider;
|
||||||
|
this.llm = opts.llm ?? 'unknown';
|
||||||
this.label = opts.label ?? 'sos-turn';
|
this.label = opts.label ?? 'sos-turn';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +75,8 @@ export class BenchSession {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stages = {
|
const stages = {
|
||||||
provider: this.provider,
|
tts: this.provider,
|
||||||
|
llm: this.llm,
|
||||||
label: this.label,
|
label: this.label,
|
||||||
'req→session': fmt(get('session-post-done')),
|
'req→session': fmt(get('session-post-done')),
|
||||||
'lyra-ttfb': fmt(get('sse-first-chunk')),
|
'lyra-ttfb': fmt(get('sse-first-chunk')),
|
||||||
@ -91,7 +94,7 @@ export class BenchSession {
|
|||||||
// console.table mit allen Markern (für strukturierte Inspektion).
|
// console.table mit allen Markern (für strukturierte Inspektion).
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(
|
console.log(
|
||||||
`[bench] ${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
|
`[bench] LLM=${this.llm} TTS=${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
|
||||||
stages,
|
stages,
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@ -99,7 +102,7 @@ export class BenchSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Snapshot für UI-Overlays (Debug-Drawer etc.). */
|
/** Snapshot für UI-Overlays (Debug-Drawer etc.). */
|
||||||
snapshot(): { provider: string; label: string; entries: MarkerEntry[] } {
|
snapshot(): { provider: string; llm: string; label: string; entries: MarkerEntry[] } {
|
||||||
return { provider: this.provider, label: this.label, entries: [...this.entries] };
|
return { provider: this.provider, llm: this.llm, label: this.label, entries: [...this.entries] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { Platform, type ImageSourcePropType } from 'react-native';
|
|||||||
|
|
||||||
export type TabKey = 'home' | 'chat' | 'coach' | 'blocker' | 'mail';
|
export type TabKey = 'home' | 'chat' | 'coach' | 'blocker' | 'mail';
|
||||||
|
|
||||||
const ANDROID_ICONS: Record<TabKey, ImageSourcePropType> = {
|
const ANDROID_ICONS: Partial<Record<TabKey, ImageSourcePropType>> = {
|
||||||
home: require('../assets/tabs/home.png'),
|
home: require('../assets/tabs/home.png'),
|
||||||
chat: require('../assets/tabs/chatbubble.png'),
|
chat: require('../assets/tabs/chatbubble.png'),
|
||||||
coach: require('../assets/tabs/sparkles.png'),
|
coach: require('../assets/tabs/sparkles.png'),
|
||||||
|
|||||||
@ -91,18 +91,34 @@
|
|||||||
},
|
},
|
||||||
"appHeader": {
|
"appHeader": {
|
||||||
"appName": "ReBreak",
|
"appName": "ReBreak",
|
||||||
"sosLabel": "SOS — Atemübung",
|
"sosLabel": "SOS",
|
||||||
"sosSubtitle": "Sofort-Hilfe bei Druck",
|
"sosTagline": "wir sind für dich da",
|
||||||
|
"sosSubtitle": "Hier lang wenn du Hilfe brauchst",
|
||||||
"editProfile": "Profil bearbeiten",
|
"editProfile": "Profil bearbeiten",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"signOut": "Abmelden"
|
"signOut": "Abmelden"
|
||||||
},
|
},
|
||||||
|
"headerMenu": {
|
||||||
|
"profile": "Profil",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"games": "ReBreak Games",
|
||||||
|
"debug": "Debug",
|
||||||
|
"logout": "Abmelden"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
"coach": "Coach",
|
"coach": "Coach",
|
||||||
"blocker": "Blocker",
|
"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": {
|
"home": {
|
||||||
"tagline": "Du gehst nicht allein.",
|
"tagline": "Du gehst nicht allein.",
|
||||||
@ -401,15 +417,43 @@
|
|||||||
"devices": "Geräte",
|
"devices": "Geräte",
|
||||||
"devices_desc": "Registrierte Geräte verwalten",
|
"devices_desc": "Registrierte Geräte verwalten",
|
||||||
"subscription": "Abonnement",
|
"subscription": "Abonnement",
|
||||||
|
"subscription_desc": "Plan & Upgrade-Pfad",
|
||||||
"plan_free": "Free",
|
"plan_free": "Free",
|
||||||
"push_notifications": "Push-Benachrichtigungen",
|
"push_notifications": "Push-Benachrichtigungen",
|
||||||
"streak_reminders": "Streak-Erinnerungen",
|
"streak_reminders": "Streak-Erinnerungen",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
|
"language_desc": "Deutsch / Englisch",
|
||||||
"language_current": "Deutsch",
|
"language_current": "Deutsch",
|
||||||
"upgrade_cta": "Auf Pro upgraden — 29 €/Jahr",
|
"upgrade_cta": "Auf Pro upgraden — 29 €/Jahr",
|
||||||
"delete_account": "Konto löschen",
|
"delete_account": "Konto löschen",
|
||||||
"delete_desc": "Alle Daten werden unwiderruflich gelöscht.",
|
"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": {
|
"urge": {
|
||||||
"title": "SOS — Atemübung",
|
"title": "SOS — Atemübung",
|
||||||
|
|||||||
@ -91,18 +91,34 @@
|
|||||||
},
|
},
|
||||||
"appHeader": {
|
"appHeader": {
|
||||||
"appName": "ReBreak",
|
"appName": "ReBreak",
|
||||||
"sosLabel": "SOS — Breathing exercise",
|
"sosLabel": "SOS",
|
||||||
"sosSubtitle": "Instant help under pressure",
|
"sosTagline": "we're here for you",
|
||||||
|
"sosSubtitle": "Tap if you need help",
|
||||||
"editProfile": "Edit profile",
|
"editProfile": "Edit profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"signOut": "Sign out"
|
"signOut": "Sign out"
|
||||||
},
|
},
|
||||||
|
"headerMenu": {
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"games": "ReBreak Games",
|
||||||
|
"debug": "Debug",
|
||||||
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"chat": "Chat",
|
"chat": "Chat",
|
||||||
"coach": "Coach",
|
"coach": "Coach",
|
||||||
"blocker": "Blocker",
|
"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": {
|
"home": {
|
||||||
"tagline": "You're not walking alone.",
|
"tagline": "You're not walking alone.",
|
||||||
@ -401,15 +417,43 @@
|
|||||||
"devices": "Devices",
|
"devices": "Devices",
|
||||||
"devices_desc": "Manage registered devices",
|
"devices_desc": "Manage registered devices",
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
|
"subscription_desc": "Plan & upgrade path",
|
||||||
"plan_free": "Free",
|
"plan_free": "Free",
|
||||||
"push_notifications": "Push notifications",
|
"push_notifications": "Push notifications",
|
||||||
"streak_reminders": "Streak reminders",
|
"streak_reminders": "Streak reminders",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"language_desc": "German / English",
|
||||||
"language_current": "English",
|
"language_current": "English",
|
||||||
"upgrade_cta": "Upgrade to Pro — €29/year",
|
"upgrade_cta": "Upgrade to Pro — €29/year",
|
||||||
"delete_account": "Delete account",
|
"delete_account": "Delete account",
|
||||||
"delete_desc": "All data will be permanently deleted.",
|
"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": {
|
"urge": {
|
||||||
"title": "SOS — Breathing exercise",
|
"title": "SOS — Breathing exercise",
|
||||||
|
|||||||
259
ops/GAMES_1V1_MIGRATION_PLAN.md
Normal file
259
ops/GAMES_1V1_MIGRATION_PLAN.md
Normal file
@ -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:<id>:<ts>`
|
||||||
|
│ filter = id=eq.<challengeId>
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ 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 `<button>`
|
||||||
|
- Memory-Board (4x4 Grid, Score-Header, Mismatch-Reveal, Progress-Bar)
|
||||||
|
- Lyra-Bubble (Avatar + animierter Phrase-Text, optional TTS-Toggle — bereits vorhandene `lib/sosTtsQueue.ts`-Infra wiederverwendbar)
|
||||||
|
- Status/Result-Section + Rematch-Button
|
||||||
|
- History-Tab + Ranking-Tab
|
||||||
|
|
||||||
|
2. `apps/rebreak-native/components/games/Game1v1Board.tsx` (optional, falls zu monolithisch) — Sub-Component für Board-Rendering.
|
||||||
|
|
||||||
|
3. **1v1-Entry-Buttons in `UrgeGames.tsx`** — analog Vue, pro TicTacToe und Memory Solo-Mode einen "Gegen echten Spieler"-Button hinzu, der `POST /api/games/challenge[-memory]` callt und auf `/game/[id]` navigiert.
|
||||||
|
|
||||||
|
4. **Community-Listing-View** — Aktuell hat RN-App keine Community-Tab. Entweder:
|
||||||
|
- **Option a:** Existing community-Page aus Nuxt nach RN portieren (separater großer Task).
|
||||||
|
- **Option b:** Erstmal nur eine **"Open Challenges"-Liste** unter `/game/index.tsx`, die alle `OPEN`-Challenges (eigener Endpoint nötig: `GET /api/games/challenges?status=OPEN`) listet.
|
||||||
|
- **Option c (empfohlen):** Direkter Invite-Flow per Share-Link `/game/[id]` (Deep-Link funktioniert bereits in Expo) — kein Community-Browsing nötig für DiGA-Cutover.
|
||||||
|
|
||||||
|
→ **Aufwand Phase C: 12-20 h** (1 Page mit 2 Game-Modes + Realtime + Lyra + History-Tab + Ranking-Tab + Lobby-Flow). Größte Position.
|
||||||
|
|
||||||
|
### Phase D — Realtime-Wiring
|
||||||
|
|
||||||
|
**Status: INFRA VORHANDEN.** `apps/rebreak-native/lib/supabase.ts` hat bereits `realtime`-Konfig.
|
||||||
|
|
||||||
|
**Zu tun:**
|
||||||
|
- Im neuen `[challengeId].tsx` analog zu Vue: `supabase.channel(...).on('postgres_changes', { schema: 'rebreak', table: 'game_challenges', filter: `id=eq.${id}` })`.
|
||||||
|
- React-Native-Spezifika: AppState-Listener für Reconnect bei Background → Foreground (Vue-Variante hat nur passive Reconnect bei `CHANNEL_ERROR`).
|
||||||
|
- Auth-Token via `supabase.realtime.setAuth(session.access_token)` — identisch zu Vue.
|
||||||
|
|
||||||
|
→ **Aufwand Phase D: 2-3 h** (im Rahmen Phase C).
|
||||||
|
|
||||||
|
### Phase E — Testing + Deploy
|
||||||
|
|
||||||
|
- **Manueller 2-Device-Test** (iOS-Simulator + Android-Emulator simultan): Challenge erstellen, accepten, abwechselnd Züge, Win/Draw, Rematch.
|
||||||
|
- **Disconnect-Resilience**: Airplane-Mode toggle während ACTIVE — Realtime muss reconnecten.
|
||||||
|
- **Deep-Link-Test**: `rebreaknative://game/<id>` aus geteiltem Link.
|
||||||
|
- **EAS-Preview-Build** für Tester.
|
||||||
|
|
||||||
|
→ **Aufwand Phase E: 4-6 h** (inkl. Bugfixes).
|
||||||
|
|
||||||
|
**Gesamtaufwand: ~20-30 h Net-Coding**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architektur-Empfehlung — was wir besser machen
|
||||||
|
|
||||||
|
Die Nuxt-Implementierung ist solide (server-authoritative + Supabase-Realtime ist die richtige Wahl). Drei Verbesserungen für die RN-Variante:
|
||||||
|
|
||||||
|
1. **Kein Community-Post-Coupling.** Die Nuxt-Variante erstellt für jede Challenge automatisch einen Community-Post (`category="challenge"`) und löscht ihn beim Spielende. Das verschmiert Game-Lifecycle und Community-Layer. Empfehlung: 1v1-Challenges leben in einer eigenen Tabelle / einem eigenen "Open-Challenges"-Endpoint, ohne Cross-Coupling. Macht Phase-C-Cleanup einfacher und entkoppelt RN-Cutover von Community-Migration.
|
||||||
|
|
||||||
|
2. **Optimistic UI mit Rollback.** Aktuell wartet Vue auf den POST-Response, bevor das Board updated → 100-300 ms Lag pro Move. RN-Variante: lokal sofort renderen (gleiche Validierungslogik clientseitig spiegeln) und auf Realtime-UPDATE reconcilen. Bei Validation-Error vom Server: rollback + Toast. Macht das Spiel "snappier" auf flackrigen Mobilfunk-Verbindungen.
|
||||||
|
|
||||||
|
3. **Heartbeat / Idle-Cancel.** Nuxt hat keinen Cleanup für tote `OPEN`-Challenges. RN-Variante: Cron-Job im Backend (`backend/server/api/cron/`-Pattern existiert bereits) markiert OPEN-Challenges nach 30 min als CANCELLED, und ACTIVE-Challenges ohne Move seit 10 min ebenfalls. Entlastet die DB und verhindert Geister-Posts.
|
||||||
|
|
||||||
|
4. **Bonus**: `gameType` zum Enum machen statt `String @default("tictactoe")` — kleine Schema-Hygiene.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Risk-Assessment
|
||||||
|
|
||||||
|
| Risk | Severity | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Realtime-Latenz auf Mobilfunk | mittel | Optimistic UI (siehe oben). 100-500 ms ist für TicTacToe/Memory ok (Async-Style). |
|
||||||
|
| Anti-Cheat / Move-Spoofing | niedrig | Server-authoritative ist bereits implementiert (Server prüft Turn + überprüft Board-State). RLS-Policies erlauben Updates nur für Teilnehmer — aber Updates passieren ohnehin via Service-Role-Backend, RLS ist nur für Realtime-Read. |
|
||||||
|
| Disconnect mid-game | mittel | Aktuell: Spiel "hängt" bis Spieler zurückkommt; kein Auto-Forfeit. Risk: Spieler quittet → Gegner stuck. Mitigation: Heartbeat + Auto-Cancel nach 10 min Inaktivität (Phase E Verbesserung) + UI-"Gegner offline"-Badge. |
|
||||||
|
| Plan-Tier-Gate | niedrig | Aktuell **kein** Plan-Check in den Endpoints — alle Auth-User können challengen. Falls Pro-Only gewünscht: in `challenge.post.ts` und `challenge-memory.post.ts` ein `requirePlan('pro')` einfügen (User-Decision). |
|
||||||
|
| Schema-Drift Nuxt vs Rebreak-Monorepo | mittel | Beide Projekte teilen physisch dieselbe DB (Schema `rebreak`). Solange beide Schemas synchron sind, kein Problem. Beim Cutover: Nuxt komplett deaktivieren, sonst race conditions auf `game_challenges`. |
|
||||||
|
| Realtime-Quota-Kosten | niedrig | Supabase Realtime hat ~200 concurrent connections im Pro-Plan. Bei 1v1 = 2 Subscriber/Match → erst ab 100 parallelen Matches problematisch. Monitoring per Supabase-Dashboard. |
|
||||||
|
| Memory-Game state.cards Größe | niedrig | 16 Cards JSON-Blob ~1 KB pro UPDATE → vernachlässigbar. |
|
||||||
|
| iOS Background WebSocket | mittel | iOS killed Realtime-Channels nach ~30 s im Background. Reconnect-on-resume zwingend (AppState-Listener). |
|
||||||
|
|
||||||
|
**Top-3 Risks:** 1) Disconnect mid-game ohne Auto-Forfeit; 2) iOS Background-WebSocket-Drop; 3) Schema-Drift während Übergangsphase Nuxt+RN parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open Questions an User
|
||||||
|
|
||||||
|
1. **Live oder Async?**
|
||||||
|
Aktuelle Nuxt-Implementierung ist **Live** (Realtime + Same-Session). Empfehlung für RN: **Live beibehalten**, weil Netcode steht. Async-Wordle-Style würde komplette Re-Architektur erfordern (Push-Notifications, Move-Queue) und 2-3x Aufwand bedeuten.
|
||||||
|
|
||||||
|
2. **Random-Matchmaking vs Friends-only vs Community-Post?**
|
||||||
|
Nuxt nutzt Community-Post (jeder kann annehmen, kein Friend-Graph). Optionen für RN:
|
||||||
|
- **a)** Community-Post-Style portieren (braucht Community-View in RN — großer separater Task).
|
||||||
|
- **b)** Public "Open Challenges"-Liste auf `/games`-Page (klein, schnell).
|
||||||
|
- **c)** Share-Link-Invite (`rebreaknative://game/<id>` per Native-Share-Sheet — kein Browse nötig).
|
||||||
|
- **d)** Random-Pool: Server-Side-Matchmaking (setzt 2 OPEN-Challenges paarweise zusammen — kein UI-Touch).
|
||||||
|
→ **User muss entscheiden.** Empfehlung: c + b kombiniert für MVP.
|
||||||
|
|
||||||
|
3. **Public Leaderboard mit Win-Rate-Stats?**
|
||||||
|
`GameScore`-Tabelle existiert + `/api/games/ranking`-Endpoint. Frage: soll Leaderboard
|
||||||
|
- **a)** in jedes Game integriert (aktueller Nuxt-Stand: Tab im `[challengeId].vue`),
|
||||||
|
- **b)** als globale Page `/games/leaderboard`,
|
||||||
|
- **c)** beides?
|
||||||
|
|
||||||
|
4. **Plan-Tier-Gate?** 1v1 für Free-Tier verfügbar oder Pro-Only? (DiGA-Relevanz unklar.)
|
||||||
|
|
||||||
|
5. **Anonymität:** Sollen Gegnernamen anonymisiert sein? Aktuell wird `nickname || username || "Anonym"` benutzt. DiGA-Datenschutz?
|
||||||
|
|
||||||
|
6. **User-Quit-Verhalten:** Wenn ein Spieler die App schließt mid-game — Forfeit nach X min, oder Spiel hängt offen? Empfehlung: Auto-Cancel nach 10 min Inaktivität, kein Forfeit (= kein Punkteabzug).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Migration-Aufwand-Summary
|
||||||
|
|
||||||
|
| Phase | Aufwand | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| A — Backend-Endpoints | 0 h | bereits portiert |
|
||||||
|
| B — DB-Migrations | 1-2 h | Schema da, SQL-Audit nötig |
|
||||||
|
| C — RN-UI Game-Page + Lobby | 12-20 h | komplett neu |
|
||||||
|
| D — Realtime-Wiring | 2-3 h | im Rahmen C |
|
||||||
|
| E — Testing + Deploy | 4-6 h | manuelle 2-Device-Tests |
|
||||||
|
| **Gesamt** | **~20-30 h** | |
|
||||||
|
|
||||||
|
Plus optional: Community-View-Migration (separater Plan) für Community-Post-Style-Matchmaking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Doc-Version 1.0 — 2026-05-07 — `~/mono/rebreak-monorepo/ops/GAMES_1V1_MIGRATION_PLAN.md`*
|
||||||
223
ops/MAESTRO_HOSTING_DECISION.md
Normal file
223
ops/MAESTRO_HOSTING_DECISION.md
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# Maestro Hosting — Decision Memo
|
||||||
|
|
||||||
|
Author: Backyard (Infrastruktur) · Stand: 2026-05-07 · Status: Recon-Empfehlung, READ-ONLY
|
||||||
|
|
||||||
|
User-Frage (2026-05-08): „lohnt sich maestro für uns oder reicht self hosted auf hetzner neue server mit backyard checken"
|
||||||
|
|
||||||
|
Dieses Doc ist die Antwort. Kurzfassung in einem Satz:
|
||||||
|
**Maestro Cloud (Starter ~10 EUR/Monat oder Free-Tier zuerst) nehmen — Self-Host
|
||||||
|
auf Hetzner lohnt sich für unsere Team-Größe + Stack nicht, vor allem weil iOS
|
||||||
|
auf Linux prinzipiell nicht testbar ist und unser CX23-Server bereits ausgelastet
|
||||||
|
ist.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Hard-Constraint: iOS lässt sich auf Linux nicht testen
|
||||||
|
|
||||||
|
Apple-Lizenz-Restriction: **iOS-Simulator läuft ausschließlich auf macOS.** Hetzner
|
||||||
|
liefert (im EU-Standard-Tier) keine Mac-Hardware. Linux-VMs auf Hetzner können
|
||||||
|
nur Android-Emulators hosten. Das heißt:
|
||||||
|
|
||||||
|
- **Self-Host auf einem Hetzner-Linux-Server kann maximal die Android-Hälfte
|
||||||
|
unserer E2E-Suite abdecken.**
|
||||||
|
- iOS-Tests müssten parallel auf einem anderen Pfad laufen:
|
||||||
|
- (a) lokaler Mac-Build (User-Hardware) — nicht CI-fähig
|
||||||
|
- (b) Mac-mini bei MacStadium o.ä. — ~60-120 EUR/Monat
|
||||||
|
- (c) GitHub-Actions macOS-Runner — ~0.08 USD/Min, pay-per-use
|
||||||
|
- (d) Maestro-Cloud nur für iOS, self-host für Android — Hybrid
|
||||||
|
|
||||||
|
Jede dieser Optionen treibt entweder Kosten oder Wartungs-Komplexität.
|
||||||
|
|
||||||
|
**Konsequenz:** „Self-Hosted-Maestro auf Hetzner" ist immer ein Hybrid-Setup,
|
||||||
|
nie eine vollständige Lösung. Wer „self-hosted" sagt, meint praktisch entweder
|
||||||
|
*nur Android* oder *Hybrid mit zweitem Pfad für iOS*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Performance-Risk auf existierendem CX23
|
||||||
|
|
||||||
|
Der dedicated rebreak-server ist eine **CX23: 2 vCPU x86, 4 GB RAM, 40 GB SSD,
|
||||||
|
2 GB swap** (siehe `reference_rebreak_server.md`). Aktuelle Last:
|
||||||
|
|
||||||
|
- `rebreak-staging` (Nuxt-Server, Nitro)
|
||||||
|
- `rebreak-imap-staging`, `rebreak-idle-staging` (Mo's IMAP-Stack)
|
||||||
|
- `rebreak-webhook` (Auto-Deploy-Listener)
|
||||||
|
- `pnpm build` während Deploy (peakt RAM)
|
||||||
|
|
||||||
|
Ein Android-Emulator braucht **2-4 GB RAM** + 1-2 vCPU für akzeptable Boot-Zeit
|
||||||
|
und stabile Test-Runs (bei `-no-window -no-audio -gpu swiftshader_indirect` am
|
||||||
|
unteren Ende). Das **kollidiert direkt** mit dem Build-Peak. OOM-Risk auf 4 GB
|
||||||
|
RAM ist real und wird in `feedback_deploy_workflow.md` schon ohne Maestro
|
||||||
|
notiert.
|
||||||
|
|
||||||
|
**Heißt:** wir KÖNNEN den CX23 nicht zusätzlich mit Android-Emulator beladen,
|
||||||
|
ohne Production-Risiko. Wir bräuchten zwingend einen **zweiten dedicated
|
||||||
|
CI-Server** (mindestens CX22 ~5 EUR/Monat netto, eher CX32 mit 8 GB RAM
|
||||||
|
~9-11 EUR/Monat um Reserve zu haben, weil Emulator + Maestro-CLI + Test-Runner
|
||||||
|
+ ggf. paralleles Android-Build sich gerne überlappen).
|
||||||
|
|
||||||
|
Damit ist der „self-hosted ist gratis"-Mythos schon entzaubert: wir reden
|
||||||
|
realistisch über **zusätzlich ~10 EUR/Monat Hetzner-Server** plus mehrere
|
||||||
|
Stunden Setup plus laufende Wartung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cost-Comparison
|
||||||
|
|
||||||
|
Annahmen: ~10 Test-Runs/Monat (TestFlight-Sprint-Phase, später ggf. mehr), je
|
||||||
|
Run ~5 Minuten Maestro-Time pro Plattform, iOS+Android parallel.
|
||||||
|
|
||||||
|
| Variante | Direkte Kosten/Monat | Direkte Kosten/Jahr | Setup-Aufwand | Laufende Wartung |
|
||||||
|
|----------|----------------------|---------------------|---------------|------------------|
|
||||||
|
| **(A) Maestro Cloud Starter** | ~10 EUR (laut Ahmed-Quote, plus Free-Tier-Monatsminuten) | ~120 EUR | 30-60 min (CLI install + cloud-token) | quasi 0 — Maestro betreibt Devices |
|
||||||
|
| **(B) Self-Host Android-only auf Hetzner (neuer CX22/32)** | 5-11 EUR (+ Strom-Pool inklusive) | 60-130 EUR | 4-8 h (Server provision + Android-SDK + Emulator-image + KVM-acceleration check + Maestro-CLI + Webhook/Trigger) | ~30 min/Monat (image-updates, Emulator-state-Drift) |
|
||||||
|
| **(B+) Self-Host Android + GitHub-Actions-iOS** | 5-11 EUR Hetzner + ~4 EUR GitHub macOS-Minutes (10×5 min) | 110-180 EUR | 6-10 h (zwei Pfade synchron halten) | ~1 h/Monat (zwei Pipelines pflegen) |
|
||||||
|
| **(C) Pure self-host iOS + Android (eigener Mac-mini bei MacStadium)** | 60-120 EUR Mac + 5-11 EUR Hetzner | 780-1570 EUR | 8-16 h | ~2 h/Monat (zwei Hosts) |
|
||||||
|
| **(D) GitHub Actions Matrix-Build (macOS-runner für beide)** | ~8 EUR (10×5 min iOS + Android Linux gratis) | ~100 EUR | 2-3 h Workflow-yml | ~30 min/Monat |
|
||||||
|
|
||||||
|
**Beobachtung:** Die nominellen Kosten von (A), (B), (B+) und (D) liegen alle
|
||||||
|
im Bereich 60-180 EUR/Jahr. Der echte Tradeoff sind **Setup-Aufwand und
|
||||||
|
Wartungs-Komplexität**, nicht Cash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Aufwand-Schätzung pro Variante
|
||||||
|
|
||||||
|
### (A) Maestro Cloud
|
||||||
|
- **Setup:** Account anlegen, `MAESTRO_CLOUD_API_KEY` als GitHub-Secret, ein
|
||||||
|
einziger CLI-Aufruf in der Action. **30-60 Min.**
|
||||||
|
- **Wartung:** Maestro pflegt Device-Pool, Updates, OS-Versionen. **Nahe 0.**
|
||||||
|
- **Risk:** External Vendor — wenn Maestro down, blockiert PR-Test. Akzeptabel
|
||||||
|
weil Tests nicht Production-blocking sind, nur PR-Gate.
|
||||||
|
|
||||||
|
### (B) Self-Host Android-only
|
||||||
|
- **Setup:** Hetzner-Server provisionieren (CX22/32), Ubuntu, Android-SDK +
|
||||||
|
cmdline-tools + emulator-image installieren, KVM/HAXM-Acceleration testen
|
||||||
|
(auf Hetzner-Cloud bedingt verfügbar — `/dev/kvm` muss exposed sein, je
|
||||||
|
nach Hetzner-Tier OK), Maestro-CLI installieren, Webhook-Endpoint bauen
|
||||||
|
oder GitHub-Actions self-hosted-runner registrieren. **4-8 h.**
|
||||||
|
- **Wartung:** Android-System-Image-Updates (alle 2-3 Monate), Emulator-state
|
||||||
|
kann korrupt werden, Maestro-CLI-Updates, Disk-cleanup für AVD-snapshots.
|
||||||
|
**~30 Min/Monat im stabilen Zustand, gelegentlich mehrere Stunden bei Bruch.**
|
||||||
|
- **Risk:** Wenn Server down, Test-Pipeline down. Plus: ein zweiter Server
|
||||||
|
vergrößert die Hetzner-Sprawl, gegen die wir gerade in
|
||||||
|
`project_rebreak_dedicated_hetzner.md` fokussiert haben.
|
||||||
|
|
||||||
|
### (B+) Hybrid Self-Host Android + Cloud-iOS oder GH-Actions-iOS
|
||||||
|
- **Setup:** wie (B) **plus** zweiter Pfad für iOS. **6-10 h.**
|
||||||
|
- **Wartung:** Zwei Pipelines, zwei Auth-Token-Pflege, zwei verschiedene
|
||||||
|
Reporting-Outputs. **~1 h/Monat.**
|
||||||
|
- **Risk:** Pipeline-Drift — Android-Test-Setup divergiert von iOS-Test-Setup,
|
||||||
|
Bugs werden auf einer Plattform gefangen, auf der anderen nicht.
|
||||||
|
|
||||||
|
### (C) Pure Self-Host inkl. Mac
|
||||||
|
- **Setup:** Mac-mini-Provider auswählen (MacStadium, Scaleway-Apple-M1, Mac
|
||||||
|
in Cloud), Maestro auf macOS installieren, iOS-Sim einrichten, plus Hetzner
|
||||||
|
für Android. **8-16 h.**
|
||||||
|
- **Wartung:** Zwei OS, zwei Toolchains, Apple-Xcode-Updates. **~2 h/Monat.**
|
||||||
|
- **Risk:** Hoch. Aufwand steht in keinem Verhältnis zum Output für unsere
|
||||||
|
Team-Größe (User + ein paar Sub-Agents).
|
||||||
|
|
||||||
|
### (D) GitHub Actions Matrix
|
||||||
|
- **Setup:** Eine `.yml` mit zwei Jobs (`maestro-ios` auf macos-latest,
|
||||||
|
`maestro-android` auf ubuntu-latest mit `reactivecircus/android-emulator-runner`).
|
||||||
|
**2-3 h.**
|
||||||
|
- **Wartung:** Action-Updates, ggf. Cache-Invalidierung. **~30 Min/Monat.**
|
||||||
|
- **Risk:** macOS-Minutes können bei vielen Test-Runs schnell teuer werden
|
||||||
|
(z.B. 100 Runs/Monat × 5 Min = 40 USD). Bei aktueller Run-Frequenz aber OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Empfehlung — Lieblings-Pfad
|
||||||
|
|
||||||
|
### Lieblings-Pfad: **Maestro Cloud Starter (Variante A)**
|
||||||
|
|
||||||
|
**Begründung:**
|
||||||
|
1. **iOS-Hard-Constraint** löst sich elegant — Cloud betreibt Apple-Devices.
|
||||||
|
2. **Setup-Aufwand minimal** — eine Stunde max, keine Hetzner-Sprawl.
|
||||||
|
3. **Hetzner-Strategie respektiert** — der CX23 bleibt fokussiert auf
|
||||||
|
`rebreak-staging`/`rebreak-prod`-Workload, kein OOM-Risk durch Emulator-Last.
|
||||||
|
4. **Ahmed-Empfehlung deckt sich** — `TESTING_STATE.md §5.2` nennt Maestro mit
|
||||||
|
Cloud-Run als bevorzugten Pfad genau aus diesem Grund.
|
||||||
|
5. **Cash-Risk niedrig** — 120 EUR/Jahr ist Rauschen verglichen mit
|
||||||
|
DiGA-Pen-Test (5-15k EUR), Apple-Developer-Program (99 USD) etc.
|
||||||
|
6. **Reversibilität hoch** — wenn Cloud-Limits gesprengt werden, lässt sich
|
||||||
|
später auf (B+) oder (D) wechseln. Maestro-yml-Flows sind portable.
|
||||||
|
|
||||||
|
**Plan-Form:**
|
||||||
|
- Anfang: **Free-Tier prüfen** (Maestro hat ein gratis Kontingent für kleine
|
||||||
|
Teams — verifizieren beim Account-Anlegen). Wenn Free-Tier ausreicht für
|
||||||
|
unsere ~10 Runs/Monat, kostet's 0.
|
||||||
|
- Wenn Free-Tier nicht reicht: Starter-Plan (~10 EUR/Monat laut Ahmed-Quote).
|
||||||
|
- Maestro-CLI lokal für Dev-Iteration, Cloud für CI-Gate auf `main`.
|
||||||
|
|
||||||
|
### Plan B: **GitHub Actions Matrix (Variante D)**
|
||||||
|
|
||||||
|
Wenn Maestro Cloud aus irgendeinem Grund nicht passt (z.B. EU-Datenschutz-Bedenken
|
||||||
|
weil Test-Recordings auf US-Servern liegen, oder Pricing-Änderung):
|
||||||
|
|
||||||
|
- GitHub-Actions-Matrix mit `macos-latest` für iOS und `ubuntu-latest` +
|
||||||
|
`reactivecircus/android-emulator-runner` für Android.
|
||||||
|
- Test-Runs nur auf `main`-Push und `workflow_dispatch`, nicht auf jedem PR
|
||||||
|
→ hält macOS-Minutes-Verbrauch klein.
|
||||||
|
- Aufwand: 2-3 h Setup, ~30 Min/Monat Wartung, ~100 EUR/Jahr.
|
||||||
|
|
||||||
|
### Anti-Empfehlung: **Self-Host auf Hetzner (B oder B+)**
|
||||||
|
|
||||||
|
Würde ich nur empfehlen wenn:
|
||||||
|
- DSGVO/DiGA-Audit verlangt explizit, dass Test-Pipelines auf EU-eigener
|
||||||
|
Infrastruktur laufen (unwahrscheinlich, weil Test-Recordings keine
|
||||||
|
Produktiv-User-Daten enthalten).
|
||||||
|
- Wir mehr als ~50 Test-Runs/Tag haben (sind wir nicht).
|
||||||
|
- Wir bereits dedicated CI-Infrastruktur betreiben (haben wir nicht — der
|
||||||
|
rebreak-server ist Production).
|
||||||
|
|
||||||
|
In allen anderen Fällen ist Self-Host für unsere Größe **Over-Engineering**
|
||||||
|
und kollidiert mit der Hetzner-Konsolidierungs-Linie aus den letzten
|
||||||
|
Backyard-Sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Setup-Estimate für die Empfehlung (Pfad A)
|
||||||
|
|
||||||
|
| Schritt | Owner | Aufwand |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| Maestro-Cloud-Account anlegen + Free-Tier-Limits checken | User | 15 Min |
|
||||||
|
| `MAESTRO_CLOUD_API_KEY` GitHub-Secret hinterlegen | User | 5 Min |
|
||||||
|
| `.github/workflows/rebreak-test.yml` Job `maestro-cloud` schreiben (`maestro cloud --apiKey $MAESTRO_CLOUD_API_KEY .maestro/`) | Ahmed/Backyard | 30 Min |
|
||||||
|
| Erster Test-Run gegen Staging-Build von TestFlight-IPA | Ahmed | 30 Min |
|
||||||
|
| Smoke-Flow `auth/sign-in.yaml` entstehen lassen + via Cloud verifizieren | Ahmed | bereits in TESTING_STATE Roadmap (1 h) |
|
||||||
|
| **Total bis erste grüne Cloud-Run** | | **~2 h** zusätzlich zur Maestro-Flow-Entwicklung selbst |
|
||||||
|
|
||||||
|
Vergleich Self-Host: 4-8 h Setup + Wartungs-Last + zweiter Hetzner-Server. Cloud
|
||||||
|
gewinnt deutlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Offene Punkte (User-Decision)
|
||||||
|
|
||||||
|
1. **Maestro-Cloud-Pricing-Verifikation:** „~10 EUR/Monat" ist Schätzung aus
|
||||||
|
Ahmed's Notiz. User sollte beim Account-Anlegen den aktuellen Starter-Plan-Preis
|
||||||
|
und das Free-Tier-Kontingent verifizieren. Backyard hat bewusst nicht selbst
|
||||||
|
den Maestro-Pricing-Endpoint gefetcht (kein Web-Tool im Recon-Scope).
|
||||||
|
2. **Cloud-Datenschutz vs DSGVO:** Maestro-Cloud-Server-Region prüfen (US vs EU).
|
||||||
|
Test-Flows enthalten keine echten User-Daten, sondern Test-Account-Daten —
|
||||||
|
Risk niedrig, aber Hans-Müller (DSFA) sollte das im Datenfluss-Diagramm
|
||||||
|
notieren.
|
||||||
|
3. **Wenn (D) als Plan B aktiv wird:** Run-Frequenz kapeln (max 1× pro PR auf
|
||||||
|
main, nicht auf jedem Branch-Push), sonst eskalieren macOS-Minutes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. TL;DR für User
|
||||||
|
|
||||||
|
- **Maestro Cloud Starter (~10 EUR/Monat, ggf. Free-Tier am Anfang) nehmen.**
|
||||||
|
- iOS lässt sich nicht auf Linux testen — Self-Host auf Hetzner ist immer
|
||||||
|
Hybrid und kostet mehr Stunden als es spart.
|
||||||
|
- Unser CX23 ist schon nah an OOM bei Build-Peaks; Android-Emulator dazu wäre
|
||||||
|
Production-Risk.
|
||||||
|
- Plan B falls Cloud nicht passt: GitHub-Actions-Matrix mit macOS-runner für
|
||||||
|
iOS, ubuntu-runner für Android. ~100 EUR/Jahr, kein eigener Server.
|
||||||
|
- Setup für Pfad A: ~2 h gesamt bis zur ersten grünen Cloud-Run.
|
||||||
|
|
||||||
|
Ende. — Backyard
|
||||||
417
ops/PROFILE_PAGE_DESIGN.md
Normal file
417
ops/PROFILE_PAGE_DESIGN.md
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
# Profile Page — Detail Design Spec
|
||||||
|
|
||||||
|
Stand: 2026-05-07
|
||||||
|
Owner: rebreak-native-ui
|
||||||
|
Phase: 1 (Detail-Plan, kein Code).
|
||||||
|
Quellen: `memory/project_profile_page_design.md`, `memory/feedback_anonymity_nickname.md`, `memory/project_llm_per_plan.md`, `ops/UI_MIGRATION_PLAN.md`.
|
||||||
|
|
||||||
|
Profile-Page ist das UI-Showpiece. Streak-Tab entfällt komplett und wandert dezent in die Profile-Page. Settings ist parallel und funktional, aber kein Showpiece.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Routing & Datenmodell-Übersicht
|
||||||
|
|
||||||
|
Zwei Views, scharf getrennt:
|
||||||
|
|
||||||
|
- `/(app)/profile` (eigenes Profil, Hero-Tab in Tab-Bar). Volle Sicht.
|
||||||
|
- `/profile/[userId]` (fremdes Profil). Anonymisiert: nur nickname, avatar, plan-tier (keine Email, keine Demographics, keine Cooldowns, keine SOS-Stats, keine Liste der blockierten Domains).
|
||||||
|
|
||||||
|
Beide laden via `apiFetch` aus eigenen Endpoints — kein client-side Filtern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visual Mock (ASCII-Wireframe)
|
||||||
|
|
||||||
|
Eigenes Profil — vertikal scrollbar, ein Screen, sechs Sektionen:
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| [<-- back] Profil [icon: cog] | <- minimal top bar
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| |
|
||||||
|
| +------------------+ |
|
||||||
|
| | | <- Avatar 96x96, runder |
|
||||||
|
| | avatar | Rahmen, plan-Akzent |
|
||||||
|
| | | (free=gray, pro=orange, |
|
||||||
|
| +------------------+ legend=gold) |
|
||||||
|
| [icon: camera-edit] |
|
||||||
|
| |
|
||||||
|
| Jonas_42 [icon: pencil] | <- nickname, inline-edit
|
||||||
|
| chahinebrini@gmail.com | <- email read-only,
|
||||||
|
| | subdued grau, klein
|
||||||
|
| [pill: legend] Mitglied seit 12.04.2026 |
|
||||||
|
| |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
|
||||||
|
STATS [icon: info]
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| +-------+ +-------+ +-------+ +-----------+ |
|
||||||
|
| | 12 | | 47 | | 134 | | 8 | |
|
||||||
|
| | Posts | | Folg- | | gebl. | | Approved | |
|
||||||
|
| | | | ower | | Dom. | | Domains > | |
|
||||||
|
| +-------+ +-------+ +-------+ +-----------+ |
|
||||||
|
| tap tap tap tap (highlight) |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
|
||||||
|
STREAK [icon: chevron] <- collapsible
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| 23 Tage geschützt |
|
||||||
|
| seit 14. April 2026 |
|
||||||
|
| |
|
||||||
|
| longest streak: 41 Tage |
|
||||||
|
| |
|
||||||
|
| COOLDOWN-VERLAUF |
|
||||||
|
| ----------------------------------- |
|
||||||
|
| | timeline-rail (1px line, vertical) | |
|
||||||
|
| | o 18.04. 16h Cooldown beendet | |
|
||||||
|
| | | "Stress nach Arbeit" | |
|
||||||
|
| | o 02.05. 4h Cooldown abgebr. | |
|
||||||
|
| | | ohne Reason | |
|
||||||
|
| | o 06.05. 24h aktiv | [pill: aktiv] |
|
||||||
|
| ----------------------------------- |
|
||||||
|
| [load more — last 30 days] |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
|
||||||
|
LYRA INSIGHTS <- SOS stats, dezent
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| Letzte 30 Tage |
|
||||||
|
| |
|
||||||
|
| 5 SOS-Sessions, 4 davon bewältigt [80% bar] |
|
||||||
|
| |
|
||||||
|
| Was hat am meisten geholfen? |
|
||||||
|
| [#] Atemübung ......... 3 Sessions |
|
||||||
|
| [#] Spiel ......... 1 Session |
|
||||||
|
| [#] Reden ......... 1 Session |
|
||||||
|
| |
|
||||||
|
| Häufigste Emotion: Stress |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
|
||||||
|
ANONYMER BEITRAG ZUR FORSCHUNG [icon: chevron] <- collapsed default
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| Optional. Hilft DiGA-Wirksamkeit zu belegen. |
|
||||||
|
| Nur aggregiert, nie personenbezogen. |
|
||||||
|
| [link: Mehr erfahren] |
|
||||||
|
| |
|
||||||
|
| ----- expanded state ----- |
|
||||||
|
| Geburtsjahr 1989 [icon: pencil] |
|
||||||
|
| Geschlecht divers [icon: pencil] |
|
||||||
|
| Familienstand ledig [icon: pencil] |
|
||||||
|
| Beruf Angestellt [icon: pencil] |
|
||||||
|
| Bundesland Bayern [icon: pencil] |
|
||||||
|
| Stadt (nicht angeg) [icon: pencil] |
|
||||||
|
| |
|
||||||
|
| [button: Einwilligung widerrufen] |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Fremdes Profil — drastisch reduziert:
|
||||||
|
|
||||||
|
```
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| [avatar 96px] |
|
||||||
|
| Jonas_42 [pill: legend] |
|
||||||
|
| Mitglied seit April 2026 |
|
||||||
|
| [button: Folgen] [button: DM senden] |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| +-------+ +-------+ +-----------+ |
|
||||||
|
| | 12 | | 47 | | 8 | |
|
||||||
|
| | Posts | | Folg- | | Approved | |
|
||||||
|
| | | | ower | | Domains | |
|
||||||
|
| +-------+ +-------+ +-----------+ |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
| Letzte Posts (5) ... |
|
||||||
|
+------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
UX-Notizen:
|
||||||
|
- Edit-Icons stehen rechts neben dem editierbaren Wert, nicht in einem zentralen "Edit-Mode". Inline-Tap öffnet Bottom-Sheet mit Input.
|
||||||
|
- Collapse-Chevron rechts oben in Section-Header, animated 180-Grad-Rotation.
|
||||||
|
- Keine Tier/Score-Kacheln (alte Nuxt-Logik), nur "Mitglied seit"-Datum + Plan-Pill.
|
||||||
|
- Plan-Pill nutzt Plan-Akzentfarbe (free=neutral-300, pro=brandOrange, legend=Goldverlauf).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Component-Tree
|
||||||
|
|
||||||
|
Routen (neu):
|
||||||
|
- `app/(app)/profile.tsx` → eigenes Profil. Wird Tab-Bar-Eintrag, ersetzt Streak-Tab.
|
||||||
|
- `app/profile/[userId].tsx` → fremdes Profil. Modal/Stack-Push.
|
||||||
|
|
||||||
|
Komponenten (neu):
|
||||||
|
|
||||||
|
```
|
||||||
|
<ProfileScreen> (top-level page, owns scroll + section refs)
|
||||||
|
<ProfileHeader> (avatar + nickname + email + plan-pill + member-since)
|
||||||
|
<AvatarPicker> (preset-grid + custom-upload trigger)
|
||||||
|
<AvatarCropSheet> (Bottom-Sheet mit Crop-UI für Custom-Photo)
|
||||||
|
<NicknameEditSheet> (Bottom-Sheet, valibot-validiert)
|
||||||
|
<StatsBar> (4 stat-cards horizontal, tappable)
|
||||||
|
<StatCard> (number + label, optional onPress)
|
||||||
|
<ApprovedDomainsSheet> (Bottom-Sheet mit Liste der approved domains)
|
||||||
|
<BlockedDomainsSheet> (analog, custom + global summary)
|
||||||
|
<StreakSection> (collapsible, owns streak + cooldown queries)
|
||||||
|
<StreakHero> (currentDays + startDate + longest)
|
||||||
|
<CooldownTimeline> (vertikale Liste, virtualisiert, paginiert)
|
||||||
|
<CooldownTimelineItem>
|
||||||
|
<LyraInsightsCard> (SOS-Stats: 30-Tage-Trend + helped-by-Bar)
|
||||||
|
<HelpedByBar> (atemuebung/spiel/reden mit Anteils-Balken)
|
||||||
|
<DemographicsAccordion>
|
||||||
|
<DemographicsConsentNotice>
|
||||||
|
<DemographicsField> (label + value + edit-icon)
|
||||||
|
<DemographicsEditSheet>
|
||||||
|
```
|
||||||
|
|
||||||
|
Komponenten (shared, reuse):
|
||||||
|
- `<Card>` — vorhanden in components/Card.tsx
|
||||||
|
- `<Button>` — vorhanden
|
||||||
|
- `<EmptyState>` — vorhanden, für "noch keine approved domains"
|
||||||
|
- Bottom-Sheet-Pattern aus `<PostCommentsSheet>` (animated, Backdrop)
|
||||||
|
- `<StreakBadge>` (existiert) — innerhalb `<StreakHero>` als "longest"-Indikator nutzbar
|
||||||
|
|
||||||
|
State-Ownership:
|
||||||
|
- `<ProfileScreen>` lädt einmalig `/api/profile/me/full` (oder kombiniert me + stats + streak + cooldowns + sos in einem Aufruf — siehe Sektion 3).
|
||||||
|
- `<NicknameEditSheet>` und `<AvatarPicker>` rufen `PATCH /api/auth/me` auf, invalidieren `useMe()` (cachedMe-Flush) + reload.
|
||||||
|
- `<ApprovedDomainsSheet>` lazy-loadet on-open `/api/profile/me/approved-domains` (separat, nicht im Initial-Bundle — Liste kann groß werden).
|
||||||
|
- `<DemographicsEditSheet>` ruft `PATCH /api/profile/demographics` auf.
|
||||||
|
- `<CooldownTimeline>` paginiert: erste 10 vom Initial-Aufruf, "load more" lädt `/api/profile/me/cooldown-history?cursor=...`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API-Endpoint-Liste
|
||||||
|
|
||||||
|
Existiert bereits:
|
||||||
|
- `GET /api/auth/me` — eigene basis. Liefert id, email, username, nickname, avatar, plan, streak (current days), created_at. KEINE Stats. KEINE Demographics.
|
||||||
|
- `PATCH /api/auth/me` — username, nickname, avatar. Muss um demographic-Felder erweitert werden ODER separater Endpoint (siehe unten).
|
||||||
|
- `GET /api/streak` — current Streak-Row.
|
||||||
|
- `GET /api/streak/events` — Streak-History (max 50). Enthält "started" / "reset" / "milestone" / "relapse". Cooldown ist DAVON GETRENNT.
|
||||||
|
- `GET /api/cooldown/status` — nur aktiver Cooldown (single).
|
||||||
|
- `GET /api/community/posts?userId=...` — vorhanden indirekt (über filter-Params).
|
||||||
|
- `GET /api/social/profile/[userId]` — fremdes Profil. Returnt nickname, avatar, followersCount, postsCount, tier, recentPosts, isFollowing. Approved-Domains nicht enthalten — muss erweitert werden.
|
||||||
|
- `GET /api/custom-domains` — eigene custom domains (active + submitted + approved + rejected).
|
||||||
|
|
||||||
|
Neu nötig:
|
||||||
|
|
||||||
|
| Endpoint | Methode | Zweck | Shape |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/api/profile/me` | GET | Aggregat: alles für eigenes Profil in einem Roundtrip | `{ profile, stats, streak, recentCooldowns[], demographics, sosInsights }` |
|
||||||
|
| `/api/profile/me/cooldown-history` | GET | Paginated cooldown-Liste | cursor-paginated, je 20, ältere ans Ende |
|
||||||
|
| `/api/profile/me/approved-domains` | GET | Liste approved domains für expanded sheet | `[{ domain, approvedAt }]` |
|
||||||
|
| `/api/profile/me/sos-insights` | GET | Aggregierte SOS-Stats (Trends, helped-by-Counts) | siehe shape unten |
|
||||||
|
| `/api/profile/me/demographics` | PATCH | Demographic-Felder setzen, audit-trail | body: subset; setzt `demographics_consent_at` automatisch auf `now()` wenn null |
|
||||||
|
| `/api/profile/me/demographics` | DELETE | Einwilligung widerrufen | nullt alle Demographic-Felder + `demographics_consent_at` |
|
||||||
|
| `/api/social/profile/[userId]` | GET | bereits da, ERWEITERN um `approvedDomainsCount` und `blockedCustomCount` (privacy: NICHT die Liste, nur Anzahl) | additive change |
|
||||||
|
|
||||||
|
Aggregat-Endpoint `/api/profile/me` Shape (Vorschlag):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
profile: { id, email, nickname, username, avatar, plan, createdAt },
|
||||||
|
stats: {
|
||||||
|
postsCount: number,
|
||||||
|
followersCount: number,
|
||||||
|
followingCount: number,
|
||||||
|
blockedCustomCount: number, // user-eigene custom domains aktiv
|
||||||
|
blockedGlobalCount: number, // size der globalen Blocklist (Kontext)
|
||||||
|
approvedDomainsCount: number, // submitted by user und approved
|
||||||
|
},
|
||||||
|
streak: { currentDays, longestDays, startDate, isActive, avgMonthlySavings },
|
||||||
|
recentCooldowns: Array<{
|
||||||
|
id, cooldownStartedAt, cooldownEndsAt, resolvedAt, cancelledAt, reason,
|
||||||
|
status: 'active' | 'resolved' | 'cancelled',
|
||||||
|
durationHours: number, // computed
|
||||||
|
}>, // erste 10
|
||||||
|
hasMoreCooldowns: boolean,
|
||||||
|
demographics: {
|
||||||
|
consentAt: string | null,
|
||||||
|
birthYear: number | null,
|
||||||
|
gender: string | null,
|
||||||
|
maritalStatus: string | null,
|
||||||
|
profession: string | null,
|
||||||
|
bundesland: string | null,
|
||||||
|
city: string | null,
|
||||||
|
},
|
||||||
|
sosInsights: {
|
||||||
|
last30Days: { sessions: number, overcome: number, overcomeRate: number },
|
||||||
|
helpedBy: { breathing: number, game: number, talk: number, other: number }, // counts
|
||||||
|
topEmotion: string | null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SOS-Insights-Aggregat: aus `sos_sessions` (existiert) + `urge_logs` (existiert). `helpedBy` durch Heuristik aus `gamesPlayed`-Json + `breathingCount` + `messages`-Length. Edge-Case: 0 Sessions → null-State, UI zeigt EmptyState "noch keine SOS-Session".
|
||||||
|
|
||||||
|
Approved-Domains-Count: count der `domain_submissions` mit `status='approved'` und `userId=user.id`. Nicht aus `user_custom_domains` — da steht der approved-Status ebenfalls, aber `domain_submissions` ist source of truth für "von dir submitted und approved".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. DB-Schema-Änderungen
|
||||||
|
|
||||||
|
Neue Spalten auf `Profile` (alle nullable, opt-in):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Profile {
|
||||||
|
// ...bestehend...
|
||||||
|
birthYear Int? @map("birth_year") // nur Jahr (1900-2024), keine vollen Geburtsdaten
|
||||||
|
gender String? // 'male' | 'female' | 'divers' | 'no_answer'
|
||||||
|
maritalStatus String? @map("marital_status") // 'single' | 'partnered' | 'married' | 'divorced' | 'widowed' | 'no_answer'
|
||||||
|
profession String? // freitext, max 80 chars
|
||||||
|
bundesland String? // ISO-3166-2:DE Code (z.B. 'DE-BY')
|
||||||
|
city String? // freitext, max 80 chars
|
||||||
|
demographicsConsentAt DateTime? @map("demographics_consent_at") @db.Timestamptz(6)
|
||||||
|
lyraVoiceId String? @map("lyra_voice_id") // siehe UI_MIGRATION_PLAN §5 — gleiche Migration mitnehmen
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration-File: `backend/prisma/migrations/20260507_add_profile_demographics_and_lyra/migration.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "rebreak"."profiles"
|
||||||
|
ADD COLUMN "birth_year" INTEGER,
|
||||||
|
ADD COLUMN "gender" TEXT,
|
||||||
|
ADD COLUMN "marital_status" TEXT,
|
||||||
|
ADD COLUMN "profession" TEXT,
|
||||||
|
ADD COLUMN "bundesland" TEXT,
|
||||||
|
ADD COLUMN "city" TEXT,
|
||||||
|
ADD COLUMN "demographics_consent_at" TIMESTAMPTZ,
|
||||||
|
ADD COLUMN "lyra_voice_id" TEXT;
|
||||||
|
|
||||||
|
-- Index nur wenn wirklich für Aggregations-Queries gebraucht (DiGA-Reports auf
|
||||||
|
-- bundesland/birthYear). Initial: keine Indizes — kann später additiv kommen.
|
||||||
|
```
|
||||||
|
|
||||||
|
Validierungen (server-side, in `me/demographics.patch.ts`):
|
||||||
|
- `birthYear`: integer, 1900..currentYear-13 (DSGVO Mindestalter 13).
|
||||||
|
- `gender`: enum-Liste oben.
|
||||||
|
- `maritalStatus`: enum-Liste oben.
|
||||||
|
- `bundesland`: regex `^DE-(BW|BY|BE|BB|HB|HH|HE|MV|NI|NW|RP|SL|SN|ST|SH|TH)$`.
|
||||||
|
- Freitext-Felder: trim, max-Length, kein HTML, kein URL-Pattern (anti-spam).
|
||||||
|
|
||||||
|
DSGVO-Audit-Trail: Setzen eines beliebigen Demographic-Feldes setzt `demographics_consent_at = now()` falls null. Widerruf (DELETE) nullt alle Felder PLUS `demographics_consent_at`. Optional zusätzlich Append-only-Log-Tabelle `demographics_consent_log` (created_at, action='granted'|'revoked', user_id) — Empfehlung: jetzt parken, reicht später nachzurüsten falls BfArM/DiGA-Audit das fordert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI-Differential-Logik (eigenes vs fremdes Profil)
|
||||||
|
|
||||||
|
Nur auf eigenem Profil (`/(app)/profile`):
|
||||||
|
- Email-Anzeige (klein, subdued, unter nickname)
|
||||||
|
- Avatar-Edit + Nickname-Edit
|
||||||
|
- Streak-Sektion komplett
|
||||||
|
- Cooldown-Timeline
|
||||||
|
- Lyra-Insights / SOS-Stats
|
||||||
|
- Demographics-Sektion (mit Edit + Widerruf)
|
||||||
|
- Liste der eigenen blockierten Custom-Domains (in Sheet)
|
||||||
|
- Liste der eigenen approved domains (in Sheet)
|
||||||
|
|
||||||
|
Auf fremdem Profil (`/profile/[userId]`):
|
||||||
|
- Avatar (resolveAvatar), nickname (Fallback username), Plan-Pill, Mitglied-seit
|
||||||
|
- Stats: Posts, Follower, Approved-Domains-Count (motivational signal — siehe Sektion 6)
|
||||||
|
- Letzte 5 Posts
|
||||||
|
- Follow-Button + DM-Button
|
||||||
|
- KEIN: email, demographics, cooldowns, sos-insights, blocked-domains-Liste
|
||||||
|
|
||||||
|
Backend-Enforcement:
|
||||||
|
- `/api/profile/me` und `/api/profile/me/*` benutzen ausschließlich `requireUser(event).id`. Kein `userId`-Param. Kein `?as=...`.
|
||||||
|
- `/api/social/profile/[userId]` returnt nie email, nie demographics, nie cooldowns, nie sos-insights — auch wenn der Caller selbst der gleiche User ist (eigener Self-View geht zwingend über `/api/profile/me`).
|
||||||
|
- Frontend hat zwei Routen, jede bindet sich an einen Endpoint. Kein gemeinsamer Component-Tree mit "isOwn"-Flag — separate Components, lehrt die Trennung im Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Risiken + Open Questions
|
||||||
|
|
||||||
|
### 6.1 Image-Cropper-Library für RN (Expo SDK 53, New Architecture)
|
||||||
|
|
||||||
|
| Library | Pro | Contra | Native-Module | Expo-kompat. |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `react-native-image-crop-picker` | Mature, native UIs (UIImagePickerController + UCrop), Cropper-Quality top | Native-Module → eject/prebuild nötig (haben wir schon, dev-client läuft), Expo-Plugin existiert nicht offiziell, Maintenance-Drift | ja | mit prebuild + manuell config-plugin |
|
||||||
|
| `expo-image-picker` + `expo-image-manipulator` | Pure Expo, keine native-Coordination | Kein interaktiver Crop — manipulator macht nur fest definierte crop-Boxes ohne UI; eigener Crop-UI = Eigenbau auf `react-native-gesture-handler` + `reanimated` | nein | ja |
|
||||||
|
| `react-native-image-cropper` (custom) | Flexibel, JS-only | Maintenance fragwürdig (unmaintained), keine native-Performance | nein | ja |
|
||||||
|
| `@react-native-community/image-editor` | offiziell genug, paired mit `expo-image-picker` | wieder kein interaktiver Crop, nur api-crop | nein | ja |
|
||||||
|
|
||||||
|
**Empfehlung:** `expo-image-picker` (existiert bereits in package.json) für Pick + ein eigenes leichtes Crop-Sheet auf `react-native-reanimated` + `react-native-gesture-handler` (existieren). Square-only-Crop reicht für Avatar-Use-Case. Vermeidet Native-Module-Coordination mit zied/backyard und hält die Expo-only-Constraint. `react-native-image-crop-picker` als Phase-5-Followup, falls Square-Crop dem User zu wenig ist und z.B. Zoom-Pan-Pinch-Quality nicht reicht.
|
||||||
|
|
||||||
|
### 6.2 Cooldown-Timeline — Liste vs Chart vs Heatmap
|
||||||
|
|
||||||
|
Optionen:
|
||||||
|
- **Liste (vertikales Timeline-Rail):** zeigt Datum, Dauer, Reason, Status pro Eintrag. Detailreich, gut lesbar, scrollbar, paginierbar.
|
||||||
|
- **Bar-Chart (horizontale x-Achse Datum, y-Achse Dauer-h):** Übersichtlich, aber Reason geht verloren, Tap-to-detail wäre extra.
|
||||||
|
- **Heatmap (calendar-grid):** Schick, aber Cooldowns sind selten (vielleicht 1-3/Monat). Heatmap mit hauptsächlich leeren Zellen wirkt leer.
|
||||||
|
|
||||||
|
**Empfehlung: Liste mit minimalem vertikalem Timeline-Rail (1px-Linie + Punkten).** Begründung: Cooldowns sind sparse Events mit narrativem Wert (Reason ist informativ — "Stress nach Arbeit"), nicht numerisch-aggregierbar. Ein Chart würde das Personal/Reflexive verlieren. Das Rail gibt visuelle Hierarchie ("damals — jetzt") ohne Charting-Overhead. Status-Pills (aktiv/beendet/abgebrochen) farb-codiert. Detail-Tap für extended Info. Das ist die "Liebe zum Detail"-Lösung: Lesefluss > Datendichte.
|
||||||
|
|
||||||
|
### 6.3 Demographic-Felder — Pflicht oder optional, wann fragen?
|
||||||
|
|
||||||
|
Hard rule: optional, opt-in, jederzeit widerrufbar (DSGVO Art-9 + DiGA).
|
||||||
|
|
||||||
|
Wann fragen:
|
||||||
|
- **Nicht im Onboarding.** Das schreckt ab und kollidiert mit dem "Du gehst nicht allein"-Brand. Onboarding bleibt schlank.
|
||||||
|
- **Nicht via aufdringliches Modal.** Kein "Hey willst du nicht…" Pop-up beim Profile-Open.
|
||||||
|
- **Genau eine Stelle:** collapsible Sektion am unteren Ende der Profile-Page mit klarem Title "Anonymer Beitrag zur Forschung" + erklärendem Subtext + Link "Mehr erfahren" (Modal mit DSGVO/DiGA-Erklärung). Komplett collapsed by default. User entdeckt es organisch.
|
||||||
|
- **Sanfter Nudge nach 30+ Tagen Streak:** EINMAL eine dezente in-app-Banner-Karte (in der Home-Feed, nicht als Modal): "Hilf der Forschung — anonyme Demographics tragen zur DiGA-Wirksamkeitsstudie bei". Tap führt zur Profile-Demographics-Section. Banner danach gedismissed via AsyncStorage.
|
||||||
|
|
||||||
|
### 6.4 Anti-Vanity-Metric — was ist motivierend?
|
||||||
|
|
||||||
|
Insta-like Stats Reihenfolge fließt psychologisch in den User ein. Vorschlag:
|
||||||
|
|
||||||
|
- **Posts** und **Follower** sind klassische Vanity-Metrics — bei Glücksspiel-Recovery können sie schädlich sein (Druck, Vergleich, "echter Aktiver"-Performance).
|
||||||
|
- **Approved-Domains** ist die einzige Metric, die den Beitrag des Users zur kollektiven Sicherheit misst — direkt rebreak-Mission-aligned.
|
||||||
|
- **Blockierte Domains** (eigene custom + globale Anzahl als Kontext) zeigt persönlichen Schutz-Stand.
|
||||||
|
|
||||||
|
**Empfehlung-Reihenfolge in Stats-Bar (links→rechts):** Posts, Follower, Geblockt, **Approved Domains** (rechts, mit dezenter Akzentfarbe brandOrange-tinted bg statt neutral, gleiche Größe — kein "biggest is best" aber visuell hervorgehoben).
|
||||||
|
|
||||||
|
Open Question für User: Sollen wir Follower-Count komplett weglassen und durch was Sinnvolleres ersetzen (z.B. "Tage geschützt" als Zahl)? "Follower" kann in Recovery-Context die falsche Dynamik triggern. Alternative: 4 Cards = Posts / Tage-geschützt / Geblockt / Approved-Domains.
|
||||||
|
|
||||||
|
### 6.5 Weitere Open Questions
|
||||||
|
|
||||||
|
1. **Plan-Pill Design** — Free=neutral, Pro=orange, Legend=gold sichtbar oder zu plakativ? Soll Legend einen subtilen Goldverlauf bekommen oder nur ein Icon?
|
||||||
|
2. **Bundesland-Erfassung** — Reicht ISO-3166-2:DE Code (16 Bundesländer)? Oder brauchen wir Stadt+PLZ für DiGA-Reporting? PLZ ist DSGVO-sensibler (3-Stellen-PLZ ist Pseudonym-grenzwertig).
|
||||||
|
3. **Email auf eigenem Profil zeigen?** Spec sagt ja. Aber subtil + read-only. Falls Sign-Up via Apple/Google: Display "via Apple Sign-In" — wir haben aktuell keinen Provider-Marker im `/api/auth/me`. Brauchen wir `provider`-Feld in der me-response? (Quick win — aus `user.app_metadata.provider`.)
|
||||||
|
4. **Member-Since Datum-Format** — "12. April 2026" oder "April 2026"? Letzteres ist privacy-friendlier auf fremden Profilen (UserId-Lookup wenn jemand das Datum kombiniert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Implementation-Reihenfolge (Phase-2/3 Vorschlag)
|
||||||
|
|
||||||
|
Phase 2 (Skeleton, dummy-Daten):
|
||||||
|
1. Tab-Bar `_layout.tsx` ergänzen um `<NativeTabs.Screen name="profile">`. Rest unverändert.
|
||||||
|
2. `app/(app)/profile.tsx` Skeleton mit 6 Sektionen, alle Hardcoded-Demo-Daten.
|
||||||
|
3. Components-Stubs anlegen (ProfileHeader, StatsBar, StreakSection, LyraInsightsCard, DemographicsAccordion, CooldownTimeline) in `components/profile/`.
|
||||||
|
|
||||||
|
Phase 3 (Wire-up):
|
||||||
|
4. `/api/profile/me` aggregat-Endpoint backend-side bauen.
|
||||||
|
5. Migration `20260507_add_profile_demographics_and_lyra` schreiben + auf staging deployen.
|
||||||
|
6. `useProfileMe`-Hook + Komponente connecten.
|
||||||
|
7. `/api/profile/me/cooldown-history` + Pagination im Frontend.
|
||||||
|
8. `/api/profile/me/sos-insights` + LyraInsightsCard.
|
||||||
|
9. `/api/profile/me/demographics` PATCH/DELETE + Edit-Sheets.
|
||||||
|
10. AvatarPicker mit expo-image-picker + Custom-Square-Crop-Sheet.
|
||||||
|
|
||||||
|
Phase 4 (Polish):
|
||||||
|
11. `/profile/[userId]` Route mit reduzierter View.
|
||||||
|
12. `/api/social/profile/[userId]` erweitern um approvedDomainsCount.
|
||||||
|
13. Skeleton-Loading-State, Empty-States, Animated-Collapse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Files (relevant für spätere Phasen)
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- `backend/prisma/schema.prisma` (Profile-Model erweitern)
|
||||||
|
- `backend/prisma/migrations/20260507_add_profile_demographics_and_lyra/migration.sql` (neu)
|
||||||
|
- `backend/server/api/profile/me.get.ts` (neu)
|
||||||
|
- `backend/server/api/profile/me/cooldown-history.get.ts` (neu)
|
||||||
|
- `backend/server/api/profile/me/sos-insights.get.ts` (neu)
|
||||||
|
- `backend/server/api/profile/me/approved-domains.get.ts` (neu)
|
||||||
|
- `backend/server/api/profile/me/demographics.patch.ts` (neu)
|
||||||
|
- `backend/server/api/profile/me/demographics.delete.ts` (neu)
|
||||||
|
- `backend/server/api/social/profile/[userId].get.ts` (extend)
|
||||||
|
- `backend/server/db/profile.ts` (extend für demographic-fields + audit-stamp)
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- `apps/rebreak-native/app/(app)/profile.tsx` (neu)
|
||||||
|
- `apps/rebreak-native/app/(app)/_layout.tsx` (Tab hinzufügen)
|
||||||
|
- `apps/rebreak-native/app/profile/[userId].tsx` (neu)
|
||||||
|
- `apps/rebreak-native/components/profile/*` (neue Komponenten-Sammlung)
|
||||||
|
- `apps/rebreak-native/hooks/useProfileMe.ts` (neu)
|
||||||
|
- `apps/rebreak-native/lib/demographics.ts` (Enum-Listen + Validierung, neu)
|
||||||
|
- `apps/rebreak-native/locales/{de,en}.json` (neue String-Namespaces `profile.*`)
|
||||||
|
- `apps/rebreak-native/components/AppHeader.tsx` (Profile-Item route → `/(app)/profile`)
|
||||||
216
ops/RELEASE_READINESS.md
Normal file
216
ops/RELEASE_READINESS.md
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# RELEASE_READINESS — TestFlight Internal + Play Internal Testing
|
||||||
|
|
||||||
|
**Owner:** zied (Release-/Rollout-Engineer)
|
||||||
|
**Stand:** 2026-05-07 (Donnerstag)
|
||||||
|
**Deadline:** Wochenende 2026-05-09/10 — iOS auf TestFlight Internal, Android auf Play Console Internal Testing
|
||||||
|
**Stack:** Expo SDK 53 + React Native 0.79.6 (NICHT Capacitor — alte zied.md ist veraltet)
|
||||||
|
**Repo-Pfad:** `/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-native/`
|
||||||
|
|
||||||
|
> Ziel "Internal Only": kein Apple-Review, kein Google-Review nötig. Apple TestFlight Internal Tester (≤100, App-Store-Connect-User) sind direkt installierbar. Play Console Internal Testing Track lässt bis zu 100 Tester per Email-Liste sofort builden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status-Quo-Tabelle
|
||||||
|
|
||||||
|
Legende: ✅ ready / 🟡 vorhanden aber Anpassung nötig / 🔴 fehlt blockierend / ⚪ User-Bestätigung nötig
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
| Item | Status | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| Bundle-ID | ✅ | `org.rebreak.app` (app.config.ts:22 + Info.plist) |
|
||||||
|
| App Store Connect entry | ⚪ | Existiert? Apple-Login nötig — User-Bestätigung |
|
||||||
|
| Apple Developer Account aktiv | ⚪ | TestFlight setzt aktiven $99/Jahr-Account voraus |
|
||||||
|
| Provisioning Profile + Cert | ⚪ | EAS managed credentials kann das, aber Account muss connected sein |
|
||||||
|
| `eas.json` | 🔴 | **fehlt komplett** — muss generiert werden für `eas build` |
|
||||||
|
| iOS Native-Folder (`ios/`) | ✅ | Existiert via expo prebuild, Workspace + Pods sind drin |
|
||||||
|
| `Info.plist` Versions | 🟡 | `CFBundleShortVersionString=0.1.0`, `CFBundleVersion=1` — für TF muss CFBundleVersion bei jedem Re-Upload steigen |
|
||||||
|
| `PrivacyInfo.xcprivacy` | ✅ | vorhanden (Pflicht seit April 2024); declared APIs FileTimestamp/UserDefaults/DiskSpace/SystemBootTime + `NSPrivacyTracking=false` |
|
||||||
|
| Entitlements | 🟡 | `aps-environment=development` — für TestFlight production-Build muss auf `production` (oder via EAS-Profile gesetzt). Family-Controls + Network-Extension + App-Group sind drin |
|
||||||
|
| Apple Sign-In | 🟡 | Code-Stub in `stores/auth.ts:82-87` mit TODO. UI zeigt "via Apple Sign-In"-Pill (`components/profile/ProfileHeader.tsx:55`). Capability fehlt im entitlements file. **Apple-Review-Risk:** Apple verlangt Sign-In-with-Apple wenn andere 3rd-Party-Logins (Google) vorhanden. Für TestFlight Internal nicht zwingend, aber für External + Production blocker |
|
||||||
|
| App-Icon (1024×1024) | ✅ | `ios/Rebreak/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png` + Contents.json |
|
||||||
|
| Splash | ✅ | `SplashScreen.storyboard` + Image-Assets im Xcasset |
|
||||||
|
| Stripe-IAP-Konflikt | 🟡 | Code prüft Pro/Legend (`stores/auth.ts`, `app/(app)/blocker.tsx:268`). UI hat KEINEN Upgrade-Button in App — alle Upgrade-Pfade sind `Alert.alert(...)` ohne Stripe-Link → für TestFlight Internal OK, aber bei External/Prod muss klar dokumentiert werden: Subscription nur via rebreak.org/web (nicht in App). Apple-Reviewer-Notes nötig |
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
| Item | Status | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| Package-ID | ✅ | `org.rebreak.app` (app.config.ts:38 + AndroidManifest.xml + build.gradle:90/92) |
|
||||||
|
| Google Play Developer Account | ✅ | Validiert 2026-05-05 (siehe trucko-memory `project_google_dev_account.md`) |
|
||||||
|
| Play Console App-Entry | ⚪ | User-Bestätigung: existiert "ReBreak"-App im Play Console schon mit Internal-Track? |
|
||||||
|
| Android Native-Folder (`android/`) | ✅ | Existiert, gradle-Setup ok |
|
||||||
|
| Keystore (Production) | 🔴 | `android/app/build.gradle:113` signed Release-Build mit **debug.keystore** — Play Store akzeptiert das nicht. Production-Keystore muss erzeugt werden. **Alternativ (empfohlen):** EAS managed Android Credentials → EAS hält Keystore, kein User-Custody-Risk |
|
||||||
|
| versionCode/versionName | 🟡 | `versionCode=1, versionName="0.1.0"` — versionCode muss bei jedem Play-Upload steigen |
|
||||||
|
| AAB-Build-Konfig | 🟡 | Keine custom AAB-Targets, default `bundleRelease` würde laufen — aber mit debug-keystore. EAS erzeugt automatisch AAB bei production-Profile |
|
||||||
|
| `eas.json` | 🔴 | fehlt — siehe iOS |
|
||||||
|
| Adaptive Icon (foreground/background) | ✅ | `adaptive-icon-android.png` (foreground only, ohne Schrift wegen Mask) + `adaptiveIcon.backgroundColor=#0a0a0a`. mipmap-xxxhdpi etc. via prebuild generiert |
|
||||||
|
| Notification-Icon (24×24 monochrome) | 🟡 | nicht explizit gesetzt — Expo nutzt fallback. Für expo-notifications im Push-Use-Case sollte ein dedicated white-icon hinterlegt werden |
|
||||||
|
| Manifest Permissions | ✅ | INTERNET, BIND_VPN_SERVICE, BIND_ACCESSIBILITY_SERVICE, FOREGROUND_SERVICE, POST_NOTIFICATIONS, RECORD_AUDIO etc. drin |
|
||||||
|
| VpnService + AccessibilityService | ✅ | im Manifest deklariert (Zeile 38-48). **Risk:** Google Play prüft AccessibilityService-Apps streng (Use-Disclosure-Form pflicht). Empfehlung unten |
|
||||||
|
| Data-Safety-Form | 🔴 | Pflicht-Form in Play Console — User muss DSGVO-Selbstauskunft ausfüllen |
|
||||||
|
| Content-Rating | 🔴 | Erwartet PEGI 12+ wegen Glücksspiel-Thematik; Form muss vor Internal-Submit ausgefüllt werden |
|
||||||
|
|
||||||
|
### Cross-Platform
|
||||||
|
|
||||||
|
| Item | Status | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| Versionierung sync (app.config.ts ↔ ios ↔ android) | 🟡 | `app.config.ts:7 version="0.1.0"` ↔ Info.plist `CFBundleShortVersionString=0.1.0` ↔ android `versionName="0.1.0"`. Synchron, aber mehrfache Locations → Sync-Skript fehlt |
|
||||||
|
| Privacy-Policy URL (öffentlich erreichbar) | 🔴 | NICHT gefunden im App-Code. **Pflicht** für App Store + Play Store. URL z.B. `https://rebreak.org/privacy` muss live sein |
|
||||||
|
| Imprint/Impressum URL | 🔴 | dito |
|
||||||
|
| Test-Account-Credentials für Reviewer | 🔴 | Erst External-Review-relevant, für Internal not blocking |
|
||||||
|
| `apiUrl`-Default im Build | 🟡 | `https://staging.rebreak.org` als fallback in app.config.ts:91 — für TF/Play-Internal sollte ein Production-API gewählt werden ODER explizit dokumentiert werden, dass Tester Staging-Backend nutzen |
|
||||||
|
| Supabase-Anon-Key hardcoded | 🟡 | app.config.ts:103 hat hardcoded staging anon-key (Comment sagt: temp bis BFF-Migration in Phase 5). Anon-Key ist designed für Client-Ship, RLS protected — aber für Production-Build sollte besser via env-Var injected werden |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internal-Only-Pfad (3 Tage — realistisch für Wochenende)
|
||||||
|
|
||||||
|
**Nötiger Minimalumfang für TestFlight Internal + Play Console Internal Testing:**
|
||||||
|
|
||||||
|
1. **`eas.json` schreiben** mit 2-3 Profilen (development, preview, production-internal)
|
||||||
|
2. **Apple Developer Account** aktiv + ASC-App "ReBreak" erstellen (User-Action)
|
||||||
|
3. **Play Console** App-Entry für `org.rebreak.app` erstellen, Internal Testing Track aktiv (User-Action)
|
||||||
|
4. **EAS Login** + projekt initialisieren (`eas init` → projectId in app.config.ts speichern)
|
||||||
|
5. **EAS managed credentials**: `eas credentials` für iOS (cert + provisioning) und Android (Keystore generieren lassen ODER existing hochladen)
|
||||||
|
6. **iOS APS-Environment** → für production-Build via EAS-Profile auf `production` setzen
|
||||||
|
7. **Build triggern:** `eas build --platform ios --profile production` + `eas build --platform android --profile production` (kostet je 1 Build-Quota — User-Approval)
|
||||||
|
8. **Submit:** `eas submit --platform ios` (lädt automatisch zu TestFlight) + `eas submit --platform android --track internal`
|
||||||
|
9. **TestFlight Internal Tester** in ASC einladen (User per Email-Liste)
|
||||||
|
10. **Play Console Internal Tester** in Play Console einladen (Email-Liste oder Google-Group)
|
||||||
|
|
||||||
|
**Was Internal NICHT braucht (im Gegensatz zu External/Production):**
|
||||||
|
- Apple-Review (nur External Beta + Public)
|
||||||
|
- Google-Review (nur Closed/Open Testing + Production)
|
||||||
|
- Privacy-Policy-URL hardcoded sichtbar (nur für Listing)
|
||||||
|
- Screenshots, Feature-Graphic, Beschreibung
|
||||||
|
- Data-Safety-Form (technisch ja, aber für Internal nicht streng durchgesetzt — sollte trotzdem ausgefüllt werden)
|
||||||
|
- Content-Rating
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External-Beta-Pfad (zusätzlich, ~1 Woche)
|
||||||
|
|
||||||
|
Wenn ihr nach Internal-Test auf External (TestFlight Public Link / Play Closed-Test) gehen wollt:
|
||||||
|
|
||||||
|
- ✅ Privacy-Policy-URL live unter `https://rebreak.org/privacy` (DSGVO-konform, mit Lyra-Memory + Auto-Extract-Klauseln, Hans-Müller-DSB review)
|
||||||
|
- ✅ Imprint live unter `https://rebreak.org/imprint`
|
||||||
|
- ✅ App Store Listing: 8+ Screenshots (iPhone 6.5", 5.5"), App-Beschreibung DE/EN
|
||||||
|
- ✅ Play Store Listing: 8+ Screenshots Phone, Feature-Graphic 1024×500, Beschreibung DE/EN
|
||||||
|
- ✅ Data-Safety-Form Play Console
|
||||||
|
- ✅ App-Content-Rating Play Console (~PEGI 12+)
|
||||||
|
- ✅ Apple-Review Notes: Demo-Account-Credentials, Hinweis dass Subscription über Web läuft (nicht IAP)
|
||||||
|
- ✅ Apple Sign-In wirklich implementiert (nicht TODO) — wenn Google-OAuth bleibt, Apple-Sign-In-Pflicht
|
||||||
|
- ✅ AccessibilityService Disclosure-Form Play Console (warum BIND_ACCESSIBILITY_SERVICE nötig — App-Schutz vor Glücksspiel-Apps)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk-Assessment
|
||||||
|
|
||||||
|
### R1 — Stripe-IAP-Konflikt (HOCH für External, MITTEL für Internal)
|
||||||
|
Apple lehnt Apps für "digital goods" ab wenn nicht via IAP gekauft. Rebreak hat Pro/Legend-Subscription via Stripe. **Aktueller App-Code zeigt KEINEN Upgrade-Button** — nur `Alert.alert("Upgrade nötig")` ohne Stripe-Link. Das ist Apple-konformer Workaround:
|
||||||
|
|
||||||
|
- App informiert User dass Pro nötig ist
|
||||||
|
- Kein Link zur Web-Bezahlseite (Apple verbietet das im App-Body)
|
||||||
|
- User abonniert auf rebreak.org/web (extern)
|
||||||
|
- App nutzt aktiviertes Abo
|
||||||
|
|
||||||
|
**Empfehlung:** für Internal-Build OK lassen. Vor External-Submit Apple-Reviewer-Note schreiben: *"Subscriptions are sold exclusively on rebreak.org. The app does not contain purchase flows. Pro features unlock automatically when the user's web account holds an active subscription."*
|
||||||
|
|
||||||
|
### R2 — VPN-Filter-Inclusion im Build (MITTEL)
|
||||||
|
Native Module `rebreak-protection` ist via `app.config.ts plugins`-Block aktiv. iOS NEFilter Extension + Android VpnService + AccessibilityService sind im Build mit drin. Für **erstes Internal-TestFlight** ist das Risiko:
|
||||||
|
|
||||||
|
- iOS: NEFilterDataProvider braucht spezielle Entitlement-Approval von Apple (Network Extensions). Falls Apple Account das nicht hat → Build schlägt beim Codesign fehl.
|
||||||
|
- Android: AccessibilityService braucht in Play Console eine Use-Disclosure beim Submit; ohne Form lehnt Google ab.
|
||||||
|
|
||||||
|
**User-Decision nötig:** Soll VPN-Filter im ersten TestFlight/Play-Internal-Build sein, oder erst V2? **Empfehlung:** für Internal Test OK behalten (kein Review), für External/Prod muss Disclosure-Form + Apple-Entitlement-Antrag laufen.
|
||||||
|
|
||||||
|
### R3 — Apple Sign-In TODO (MITTEL für External)
|
||||||
|
`stores/auth.ts:82-87` ist TODO. UI zeigt aber Apple-Pill. Bei External-Beta verlangt Apple Sign-In-with-Apple wenn andere 3rd-Party-Logins (Google) da sind — Pflicht-Guideline 4.8. Für Internal ist das egal (kein Review).
|
||||||
|
|
||||||
|
### R4 — Production-Keystore-Custody (HOCH)
|
||||||
|
Aktuell ist nur Debug-Keystore eingerichtet. Bei eigenem Keystore: User hält Passwort, Backup-Pflicht (verloren = Update-Path tot). **Empfehlung:** EAS managed Credentials (EAS hält Keystore, ist Industry-Standard, kein User-Custody-Stress).
|
||||||
|
|
||||||
|
### R5 — Hardcoded Supabase Anon-Key in app.config.ts (NIEDRIG)
|
||||||
|
`app.config.ts:103` hat hardcoded staging anon key. Anon-keys sind designed for client-ship (RLS protected DB). Aber für Production sollte env-injection. Internal-Build OK.
|
||||||
|
|
||||||
|
### R6 — Backend-API-Pointer für Tester (MITTEL)
|
||||||
|
`apiUrl` default ist `https://staging.rebreak.org`. Tester nutzen also Staging-DB. Wenn Prod-DB getrennt: Datenstand divergent. **User-Decision:** Internal-Tester gegen Staging oder schon Prod-Backend?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TODO-Liste sortiert nach Tag
|
||||||
|
|
||||||
|
### Donnerstag 2026-05-07 (HEUTE — Recon + Doc)
|
||||||
|
- [x] `RELEASE_READINESS.md` schreiben
|
||||||
|
- [ ] **User:** Antworten auf Open Questions (siehe unten)
|
||||||
|
- [ ] **User-Bestätigung:** TestFlight & Play-Internal-Targeting (Internal-only oder gleich External?)
|
||||||
|
|
||||||
|
### Freitag 2026-05-08 (Setup + Configs, alles ohne Build-Trigger)
|
||||||
|
- [ ] zied: `eas.json` mit production-Profile schreiben (User reviewt vor Commit)
|
||||||
|
- [ ] zied: `app.config.ts` cleanup wenn nötig (env-vars für apiUrl)
|
||||||
|
- [ ] zied: Privacy-Policy + Imprint Stub-Pages (oder im rebreak-backend webroot deployen — Koordination mit rebreak-ops)
|
||||||
|
- [ ] **User:** Apple Developer Account-Status verifizieren ($99/Jahr aktiv?)
|
||||||
|
- [ ] **User:** ASC-App "ReBreak" erstellen (Bundle org.rebreak.app)
|
||||||
|
- [ ] **User:** Play Console App-Entry erstellen + Internal-Test-Track + Tester-Liste
|
||||||
|
- [ ] **User:** `eas login` + `eas init` (nur User kann sich einloggen)
|
||||||
|
- [ ] **User:** Decision EAS-managed Credentials vs eigener Keystore
|
||||||
|
- [ ] zied: VersionCode-Bump-Pattern dokumentieren
|
||||||
|
|
||||||
|
### Samstag 2026-05-09 (Build + Submit, alles mit User-Approval)
|
||||||
|
- [ ] **User-Approval + Trigger:** `eas build --platform ios --profile production-internal`
|
||||||
|
- [ ] **User-Approval + Trigger:** `eas build --platform android --profile production-internal`
|
||||||
|
- [ ] zied: Build-Output validieren (size, sha)
|
||||||
|
- [ ] **User-Approval + Trigger:** `eas submit --platform ios` (TestFlight) + `eas submit --platform android --track internal`
|
||||||
|
- [ ] **User:** TestFlight Tester-Emails einladen via ASC
|
||||||
|
- [ ] **User:** Play Console Internal-Tester-Emails einladen
|
||||||
|
- [ ] zied: Smoke-Test-Plan auf User-Device (Install, Login, Streak, Lyra-Chat 1 Roundtrip)
|
||||||
|
|
||||||
|
### Sonntag 2026-05-10 (Buffer + Iteration)
|
||||||
|
- [ ] Bugfix-Round 1 falls Tester-Feedback (rebreak-native-ui handled UI-Bugs)
|
||||||
|
- [ ] Falls neue Build nötig: versionCode++ → re-upload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-5 User-Actions die nicht verschoben werden können
|
||||||
|
|
||||||
|
1. **Apple Developer Account login + ASC-App-Entry erstellen** (org.rebreak.app, Bundle-ID, App-Name "ReBreak"). Ohne ASC-Entry kein TestFlight.
|
||||||
|
2. **Play Console App-Entry + Internal-Track aktivieren** für `org.rebreak.app`. Ohne Track kein Internal-Upload.
|
||||||
|
3. **`eas login` + `eas init`** im `apps/rebreak-native/` ausführen — registriert Projekt + speichert projectId.
|
||||||
|
4. **Decision EAS-managed-Credentials vs eigener Keystore** + ggf. Keystore-Passwort generieren/sichern (User-Custody, NIEMALS commit).
|
||||||
|
5. **Privacy-Policy + Imprint live unter rebreak.org-URLs** — auch für Internal empfohlen (Vermeidet Hick-Up beim späteren External-Switch). Für External strikt Pflicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Realistischer Wochenend-Plan — Machbarkeit
|
||||||
|
|
||||||
|
**Ja, Internal-TestFlight + Play-Internal sind machbar bis Sonntag 2026-05-10**, sofern:
|
||||||
|
|
||||||
|
- Apple Dev Account heute/morgen früh reaktiviert/bestätigt
|
||||||
|
- User Freitag Mittag verfügbar für `eas login` + ASC/Play-Console-Setup
|
||||||
|
- Keine Apple-Entitlement-Hick-Ups beim ersten iOS-Codesign (NEFilter braucht Entitlement-Approval — falls Apple Account das nicht hat, müssen wir die Plugin-Aktivierung temporär entfernen für Internal Build → V2 mit VPN-Filter)
|
||||||
|
- EAS-Build-Quota verfügbar (Free-Tier hat ~30 Builds/Monat — sollte reichen)
|
||||||
|
|
||||||
|
**Realistischer Risk-Pfad:** Falls iOS-NEFilter-Entitlement nicht da ist, Plan B = Internal-TestFlight ohne VPN-Filter-Plugin (1-Line plugin-comment-out) → User-Test der Core-Loop (Streak/Lyra/Posts/Games) ohne Blocker-Feature. VPN-Filter dann V2.
|
||||||
|
|
||||||
|
**External-Beta (TestFlight Public Link / Play Closed-Test) Wochenend-Ziel = NICHT realistisch.** Privacy-Policy-Live + Apple-Review-Process + Sign-In-with-Apple-Implementierung würden mind. 1 weitere Woche brauchen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 wichtigste Open Questions an User
|
||||||
|
|
||||||
|
### Q1 — VPN-Filter im ersten Build dabei oder rauslassen?
|
||||||
|
Plugins `with-rebreak-protection-ios` (NEFilter Extension) + `with-rebreak-protection-android` (VpnService + AccessibilityService) sind aktiv. Risk bei NEFilter: braucht spezielle Apple-Entitlement (Network Extensions Capability), die nicht im Standard-Dev-Account drin ist — muss von Apple separat genehmigt werden (1-2 Wochen Wartezeit historisch).
|
||||||
|
**Optionen:**
|
||||||
|
- (A) NEFilter+VpnService dabei → erstmaliger Build kann an Codesign scheitern, falls Entitlement fehlt
|
||||||
|
- (B) Plugins temporär raus (1-Line Comment in app.config.ts), Core-App testflighten, VPN-Filter V2 mit eigenem Build-Cycle
|
||||||
|
- (C) Erst Apple-Entitlement-Status prüfen (User-Login ASC), dann entscheiden
|
||||||
|
|
||||||
|
### Q2 — Backend-API-Pointer für Internal-Tester: Staging oder Prod?
|
||||||
|
Default `apiUrl=https://staging.rebreak.org`. Tester schreiben dann in Staging-DB. Wenn Prod-DB schon läuft: Daten-Inkonsistenz. Wenn Test-DB OK: weiter mit Staging.
|
||||||
|
**Meine Empfehlung:** Internal-Tester auf Staging (klarer Test-Modus, keine Vermischung mit Production-User). Externe Beta dann auf Prod.
|
||||||
|
|
||||||
|
### Q3 — Keystore-Strategie Android: EAS managed oder eigener Custody?
|
||||||
|
EAS managed = EAS speichert keystore verschlüsselt, kein User-Custody, automatisch im Build verwendet. Industry-Standard. Recovery via Expo-Account.
|
||||||
|
Eigener = User generiert keystore lokal, hält Passwort, lädt zu EAS hoch via `eas credentials`. Mehr Kontrolle, mehr Custody-Risk.
|
||||||
|
**Meine Empfehlung:** EAS managed für Wochenend-Speed, später kann User keystore mit `eas credentials --platform android` exportieren wenn Custody gewünscht.
|
||||||
320
ops/TESTING_STATE.md
Normal file
320
ops/TESTING_STATE.md
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
# Rebreak — Testing State Audit & Roadmap
|
||||||
|
|
||||||
|
Author: Ahmed (QA) · Stand: 2026-05-07 · Status: Initial Audit, READ-ONLY
|
||||||
|
|
||||||
|
Dieses Dokument ist eine ehrliche Bestandsaufnahme. Antwort auf User-Frage:
|
||||||
|
„was und wie testen wir, welche Abdeckungsrate, wo drückt der Schuh".
|
||||||
|
|
||||||
|
Kurzfassung in einem Satz: Im neuen `rebreak-monorepo` existiert **null automatisierte
|
||||||
|
Test-Coverage**. Nicht „wenig" — **Null**. Die alte trucko-Suite (114 Vitest-Tests +
|
||||||
|
Postman + Playwright) deckt eine andere Architektur (Nuxt + Capacitor) ab und ist
|
||||||
|
nicht 1:1 portierbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Status quo (rebreak-monorepo)
|
||||||
|
|
||||||
|
### 1.1 Backend (`backend/`, Nitro standalone, Prisma + Supabase)
|
||||||
|
|
||||||
|
| Aspekt | Stand |
|
||||||
|
| ------------------- | ------------------------------------------------------------ |
|
||||||
|
| Test-Verzeichnis | nicht vorhanden (`tests/`, `__tests__/`, `*.test.ts` = leer) |
|
||||||
|
| Test-Framework | nicht installiert |
|
||||||
|
| Coverage-Tool | keins |
|
||||||
|
| `package.json` test | kein `test`-Script vorhanden |
|
||||||
|
| API-Endpoints | 122 Files in `server/api/**` — 0 abgedeckt |
|
||||||
|
| DB-Layer | 16 Files in `server/db/**` — 0 abgedeckt |
|
||||||
|
| Utils | 11 Files in `server/utils/**` — 0 abgedeckt |
|
||||||
|
| Middleware | `cors.ts` — 0 abgedeckt |
|
||||||
|
|
||||||
|
**Coverage-Schätzung Backend: 0 %.**
|
||||||
|
|
||||||
|
### 1.2 Frontend (`apps/rebreak-native/`, Expo SDK 53 + RN 0.79)
|
||||||
|
|
||||||
|
| Aspekt | Stand |
|
||||||
|
| ------------------- | ---------------------------------------------------------------- |
|
||||||
|
| Test-Verzeichnis | nicht vorhanden |
|
||||||
|
| `.maestro/` | nicht vorhanden |
|
||||||
|
| Test-Framework | weder `jest-expo` noch `@testing-library/react-native` installed |
|
||||||
|
| `package.json` test | nur `lint` + `typecheck`, kein `test` |
|
||||||
|
| Components | 58 Files — 0 abgedeckt |
|
||||||
|
| Hooks | 13 Hooks (`useMe`, `useUserPlan`, …) — 0 abgedeckt |
|
||||||
|
| Stores | 4 Zustand-Stores (`auth`, `coach`, `community`, `notifications`) — 0 abgedeckt |
|
||||||
|
| Lib (sosStream, llmProvider, ttsProvider, lyraResponse) | 17 Files — 0 abgedeckt |
|
||||||
|
| App-Routes | 11 Top-Level (`urge.tsx`, `lyra.tsx`, `auth/*`, `(app)/*`, `profile/*`) — 0 abgedeckt |
|
||||||
|
|
||||||
|
**Coverage-Schätzung Frontend: 0 %.**
|
||||||
|
|
||||||
|
Native-Module-Pfad `apps/rebreak-native/modules/rebreak-protection/android/src/test`
|
||||||
|
existiert als leerer Gradle-Skeleton, ohne reale Test-Klassen — Backyard's Scope.
|
||||||
|
|
||||||
|
### 1.3 CI / CD
|
||||||
|
|
||||||
|
- `.github/workflows/` existiert nicht im rebreak-monorepo.
|
||||||
|
- Es gibt **keinen** `rebreak-unit`-Job (existierte in trucko-monorepo, wurde nicht migriert).
|
||||||
|
- `xgit`-Deploy-Pipeline buildet ohne Tests durchzulaufen.
|
||||||
|
- Pre-commit-Hooks: keine.
|
||||||
|
- TypeScript ist die einzige stille Schutzlinie (`pnpm typecheck` in beiden Paketen).
|
||||||
|
|
||||||
|
### 1.4 Zusammenfassung
|
||||||
|
|
||||||
|
> 0 Tests, 0 CI-Gate, 0 Coverage. Die Auth-Middleware-500-Cascade dieser Woche wäre
|
||||||
|
> mit einem 8-Zeilen-Vitest-Test für `requireUser()` verhindert worden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Heritage aus trucko-monorepo (was portierbar / inspirierend ist)
|
||||||
|
|
||||||
|
Pfad: `~/mono/trucko-monorepo/apps/rebreak/tests/`
|
||||||
|
|
||||||
|
### 2.1 Vitest Unit-Suite — gold-wert, ~70 % portierbar
|
||||||
|
|
||||||
|
`tests/unit/` — 6 Dateien, 114 Tests, alle grün (geprüft 2026-04-28):
|
||||||
|
|
||||||
|
| Datei | Was getestet | Portierbar? |
|
||||||
|
| --------------------------- | ------------------------------------------------------------- | ------------------------ |
|
||||||
|
| `cooldown.test.ts` | `signCooldownToken`, `verifyCooldownToken`, DB-Layer | Ja, 1:1 |
|
||||||
|
| `domain-blocklist.test.ts` | `getBlocklistedDomainsSet` Subdomain-Matching | Ja, 1:1 |
|
||||||
|
| `domain-limits.test.ts` | Plan-Limit-Enforcement bei Custom-Domains | Ja, 1:1 |
|
||||||
|
| `domain-validation.test.ts` | Domain-Regex + Sanitisierung | Ja, 1:1 |
|
||||||
|
| `domain-vote.test.ts` | `castDomainVote` Auto-Approve-Logik | Ja, 1:1 |
|
||||||
|
| `plan-features.test.ts` | `PLAN_LIMITS`, `getPlanLimits` für free/pro/legend | Ja, mit Tier-Update |
|
||||||
|
|
||||||
|
Alle nutzen `vi.mock('../../server/utils/prisma', …)` — keine echte DB nötig, läuft
|
||||||
|
in Sekunden, kein Secret-Setup. **Quick win:** copy + adjust import-paths
|
||||||
|
(`apps/rebreak/server/...` → `backend/server/...`).
|
||||||
|
|
||||||
|
### 2.2 Postman-Collection — sofort wiederverwendbar
|
||||||
|
|
||||||
|
`tests/postman/rebreak.postman_collection.json` deckt 12 Domains (Auth, Profile,
|
||||||
|
Streaks, Posts, Chat, Lyra, Notifications, Cooldown, Domain-Submissions,
|
||||||
|
Blocklist, Scores, Sicherheit-401-Sammlung).
|
||||||
|
|
||||||
|
Anpassungs-Aufwand:
|
||||||
|
- `{{baseUrl}}` ist URL-only, funktioniert für Nitro-standalone identisch.
|
||||||
|
- Auth-Pfad bleibt `POST /api/auth/login` — selbe Route in neuem Backend.
|
||||||
|
- Token-Auto-Extract-Skript funktioniert, da Backend Bearer-Token zurückgibt.
|
||||||
|
- Eventuell veraltete Endpoints (`/api/blocklist/*`, `/api/scores/*`) gegen neue
|
||||||
|
routes der Standalone-API verifizieren.
|
||||||
|
|
||||||
|
### 2.3 Playwright Smoke (`tests/e2e/smoke/`)
|
||||||
|
|
||||||
|
**Nicht portierbar** für Native-App. Nuxt-DOM-Selektoren, Browser-only, irrelevant
|
||||||
|
für Expo. `fixtures/auth.ts` (`loginAs(page, …)`) ist konzeptuell wiederverwendbar
|
||||||
|
für Maestro `JS-onFlowStart` — aber neu schreiben.
|
||||||
|
|
||||||
|
### 2.4 Cypress-Suite (`apps/rebreak/cypress/e2e/`)
|
||||||
|
|
||||||
|
`01-smoke.cy.ts`, `02-a11y.cy.ts` — Capacitor-Webview, irrelevant für RN. Skip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Risk-Matrix — Top 10 ungetestete Critical Paths
|
||||||
|
|
||||||
|
Bewertung: **Risk = max(DSGVO-Risk, User-Trust-Risk, DiGA-Risk, Recovery-Risk)**.
|
||||||
|
|
||||||
|
| # | Pfad | Risk | Why | Empf. Test-Form |
|
||||||
|
| -- | ------------------------------------------------------ | -------- | ------------------------------------------------------------------------------ | -------------------------------- |
|
||||||
|
| 1 | `server/utils/auth.ts` → `requireUser()` | KRITISCH | Diese Woche 500-Cascade durch Bug. Jeder Endpoint hängt dran. | Unit (vitest) + supertest |
|
||||||
|
| 2 | `coach/sos-stream.get.ts` (Tier-LLM-Switch) | KRITISCH | Free/Pro=Groq, Legend=Haiku. Falscher LLM für falschen User = User-Trust-Bruch + Brand-Voice-Bruch. | Integration mit Plan-Mock |
|
||||||
|
| 3 | `coach/sos-stream.get.ts` (SSE-Stream + Chip-JSON) | KRITISCH | SOS ist Recovery-Schutz. Stream-Bruch oder Chip-Parse-Fail = User in Krise allein. | Integration + Stream-Snapshot |
|
||||||
|
| 4 | `community/post.post.ts` + `posts.get.ts` (Anonymität) | KRITISCH | DSGVO Art. 9 + Glücksspiel-Stigma. Wenn UI plötzlich `firstName`/`username` statt `nickname` liefert = Disaster. | Unit (DB-select-shape) + e2e |
|
||||||
|
| 5 | `cooldown/{request,status,cancel}.post/get.ts` | HOCH | DiGA-relevant Recovery-Schutz. Token-Sign/Verify war in trucko getestet — muss in neue Codebase portiert. | Unit (vitest, gemockt) |
|
||||||
|
| 6 | `urge/index.post.ts` + `lib/sosTtsQueue.ts` | HOCH | Urge-Logging ist Recovery-Kern-Feature. TTS-Queue = aktuell offen-modifiziert (siehe `git status`). | Unit + Component-Test |
|
||||||
|
| 7 | `auth/login.post.ts` + `auth/me.get.ts`/`me.patch.ts` | HOCH | Profile-Update = Demographics = DiGA-Daten. Bug = falsche Kohorten-Daten in BfArM-Antrag. | Unit (Zod-validate) + Postman |
|
||||||
|
| 8 | `stripe/webhook.post.ts` + Pro-Trial-Reward-Trigger | HOCH | Pro-Trial-Logik (Phase C, kommt). Missing Test = Free-User bekommen versehentlich Lifetime-Pro oder umgekehrt. | Unit + Integration mit Stripe-mock |
|
||||||
|
| 9 | `lib/llmProvider.ts` + `lib/sosStream.ts` (Frontend) | HOCH | Resolved-Provider-Logik passt zum Backend. Wenn Drift entsteht, sehen User falschen LLM-Pill (UX-Lüge). | Unit (RN-jest) |
|
||||||
|
| 10 | `mail/scan.post.ts` + `mail/connect.post.ts` | MITTEL | Mo's Scope, aber Recovery-relevant. Mail-Scan-Bug = User bekommt Casino-Newsletter durchgelassen. | Integration |
|
||||||
|
|
||||||
|
Nicht in Top 10 aber erwähnenswert:
|
||||||
|
- `protection/*` (URL-Filter-Blocklist) — DiGA-relevant, aber binär/idempotent
|
||||||
|
- `scores/*` (Leaderboard) — Anonymität-Risk via `feedback_anonymity_nickname.md`
|
||||||
|
- `notifications/*` — Push-Anrede mit `firstName` statt `nickname` = Anonymitäts-Bruch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Roadmap
|
||||||
|
|
||||||
|
### 4.1 Pre-TestFlight (Wochenende, Mai 2026) — absolutes Minimum
|
||||||
|
|
||||||
|
Ziel: App stürzt im TestFlight nicht beim Launch ab + die zwei kritischsten User-Journeys (Login + SOS-Trigger) funktionieren auf einem echten iPhone-Build.
|
||||||
|
|
||||||
|
| Task | Form | Aufwand |
|
||||||
|
| ----------------------------------------------------------------- | ------------- | ------- |
|
||||||
|
| Maestro CLI installieren + `apps/rebreak-native/.maestro/` anlegen | Setup | 30 min |
|
||||||
|
| Smoke-Flow `auth/sign-in.yaml` (Username + Pwd → App-Shell) | Maestro | 1 h |
|
||||||
|
| Smoke-Flow `sos/start.yaml` (Login → SOS-Button → Chat-Stream sieht 1+ Token) | Maestro | 1.5 h |
|
||||||
|
| Smoke-Flow `community/feed-loads.yaml` (Login → Feed → 1+ Post sichtbar) | Maestro | 1 h |
|
||||||
|
| Manueller Postman-Run der 12-Domain-Collection gegen Staging | manuell | 1 h |
|
||||||
|
| Backend-Sanity: `pnpm build:backend` + curl-Smoke gegen 5 wichtigste Endpoints (`/api/auth/me`, `/api/coach/sos-stream`, `/api/community/posts`, `/api/cooldown/status`, `/api/urge`) | manuell | 30 min |
|
||||||
|
|
||||||
|
**Wichtig:** das ist NUR Smoke. Keine Plan-Tier-Permutationen, keine Edge-Cases.
|
||||||
|
„Stürzt nicht ab + Happy-Path geht durch" ist das einzige Bestehen-Kriterium.
|
||||||
|
|
||||||
|
### 4.2 Post-TestFlight (Woche 1-2 nach Wochenend-Cutover)
|
||||||
|
|
||||||
|
| Task | Form | Aufwand |
|
||||||
|
| ------------------------------------------------------------------------------------- | ------------ | ------- |
|
||||||
|
| Vitest setup im `backend/` (vitest.config.ts, `pnpm add -D vitest @vitest/coverage-v8`) | Setup | 30 min |
|
||||||
|
| Port `cooldown.test.ts` von trucko nach `backend/tests/unit/` | Port + adjust| 1 h |
|
||||||
|
| Port `plan-features.test.ts` (mit Tier-Update legend-Pfad) | Port + adjust| 1 h |
|
||||||
|
| Port `domain-{blocklist,limits,validation,vote}.test.ts` | Port + adjust| 2 h |
|
||||||
|
| **NEU** `auth-requireUser.test.ts` — Bug-Replikation (Bearer fehlt, Bearer invalid, Plan-Lookup fail) | Unit | 2 h |
|
||||||
|
| **NEU** `lyraMemoryExtract.test.ts` — Klarnamen-Replacement-Pipeline | Unit | 1 h |
|
||||||
|
| Supertest-Setup für Integration (Nitro `.output/server` startable im Test) | Setup | 2 h |
|
||||||
|
| Integration: `auth/login` + `auth/me` Happy + 401-Pfad | Integration | 2 h |
|
||||||
|
| GitHub Action `rebreak-test.yml` — Job `backend-unit` (vitest run + coverage) | CI | 1 h |
|
||||||
|
| Postman-Collection nach `backend/tests/postman/` portieren + `{{baseUrl}}` auf staging.rebreak.org | Port | 2 h |
|
||||||
|
|
||||||
|
Coverage-Ziel nach Phase 2: **40 % Backend-Statements, 100 % der `utils/`-Files,
|
||||||
|
100 % `requireUser`-Branches.**
|
||||||
|
|
||||||
|
### 4.3 Phase C (Demographics + Pro-Trial) — TDD parallel
|
||||||
|
|
||||||
|
User-Auftrag steht: Demographics-Form + 1-Woche-Pro-Trial-Reward. Hier MUSS Test
|
||||||
|
parallel zur Implementation laufen, weil:
|
||||||
|
- Reward-Trigger-Bug = Free-Lifetime-Pro oder Trial-nie-revoked (Stripe-revenue-leak)
|
||||||
|
- DSGVO-Wording-Pflicht („nicht Daten-gegen-Geld")
|
||||||
|
- DiGA-Daten-Integrität
|
||||||
|
|
||||||
|
| Test | Form |
|
||||||
|
| ----------------------------------------------------------------------- | ----------- |
|
||||||
|
| `profile/me/demographics.patch.test.ts` — alle 5 Felder gesetzt → Trial | Unit |
|
||||||
|
| `profile/me/demographics.patch.test.ts` — Free + bereits Trial-used → kein Re-Trial | Unit |
|
||||||
|
| `profile/me/demographics.patch.test.ts` — Pro/Legend → kein Trial-Trigger | Unit |
|
||||||
|
| Cron-Test: `pro-trial-revoke.cron.test.ts` — nach 7d zurück auf free, sofern kein Stripe-Abo | Unit |
|
||||||
|
| Maestro `profile/demographics-fill.yaml` — Free-User füllt alle Felder, sieht Pro-Reward-Banner | E2E |
|
||||||
|
|
||||||
|
### 4.4 DiGA-Pathway — Hans-Müller-Frage
|
||||||
|
|
||||||
|
BfArM erwartet für DiGA-Approval (laut `hans-mueller.md` Scope):
|
||||||
|
- **Penetrationstest-Bericht** (jährlich) — externer Anbieter, nicht Ahmed's Scope
|
||||||
|
- **DSFA-Dokument** (Datenschutz-Folgenabschätzung) — Hans-Müller's Scope
|
||||||
|
- **ISO 27001 oder BSI-Grundschutz** — DevOps/Backyard's Scope
|
||||||
|
|
||||||
|
Was BfArM **nicht direkt** verlangt aber bei Audit fragt:
|
||||||
|
- „Ist Ihre Software automatisiert getestet? Welche Coverage?"
|
||||||
|
- „Wie verhindern Sie Regressionen bei Updates?"
|
||||||
|
- „Wie testen Sie Crisis-Detection-Logik?"
|
||||||
|
|
||||||
|
**Ahmed-Empfehlung:** für DiGA-Antrag streben wir an: **70 % Backend-Coverage,
|
||||||
|
60 % Frontend-Coverage, 100 % Crisis-Detection-Recall (Lyra-Eval-Suite),
|
||||||
|
dokumentierter Test-Plan + CI-Pipeline-Gate.** Das ist nicht Pflicht aber
|
||||||
|
glaubwürdigkeits-prägend. **Frage an User: soll Hans-Müller das im DSFA-Dokument
|
||||||
|
explizit machen?**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tooling-Empfehlungen
|
||||||
|
|
||||||
|
### 5.1 Backend
|
||||||
|
|
||||||
|
- **Test-Framework: vitest 2.x.** Schnell (esbuild), TS-nativ, `vi.mock`-Pattern
|
||||||
|
matched 1:1 unsere trucko-Heritage. Kein Jest (langsamer, schlechtere TS-DX in
|
||||||
|
Nitro-Welt). Bun-test ist verlockend aber nitro+prisma stack ist node-validiert.
|
||||||
|
- **Coverage: `@vitest/coverage-v8`.** Built-in, schnell, accurate.
|
||||||
|
- **Integration-Tests: supertest** gegen ein in-process Nitro-`.output/server`
|
||||||
|
bundle. Alternativ: `pnpm dev` als Sidecar in CI starten und gegen
|
||||||
|
`localhost:3001` fetchen.
|
||||||
|
- **Mocking: Prisma via `vi.mock('../../server/utils/prisma', …)`** wie in trucko.
|
||||||
|
Supabase-Client per `vi.mock('@supabase/supabase-js', …)`.
|
||||||
|
- **Test-DB: NICHT setzen** in Phase 1+2. Mock reicht. Wenn später
|
||||||
|
Migration-Tests gebraucht: Postgres-Container in CI.
|
||||||
|
|
||||||
|
### 5.2 Frontend (Native)
|
||||||
|
|
||||||
|
- **Component-Tests: `jest-expo` + `@testing-library/react-native`.** Standard
|
||||||
|
Expo-Stack, gut dokumentiert. Snapshot-Tests sparsam (nur für stabile Strings,
|
||||||
|
kein i18n-Output).
|
||||||
|
- **E2E: Maestro.** Bereits in `ahmed.md` als Standard definiert. iOS+Android
|
||||||
|
YAML-Flows, kein Detox-Maintenance-Hell. Cloud-Run möglich für CI.
|
||||||
|
**NICHT Detox** — Setup-Aufwand zu hoch für unsere Team-Größe.
|
||||||
|
- **`testID`-Konvention:** `<area>-<element>-<action>`, z.B. `auth-username-input`,
|
||||||
|
`urge-sos-trigger-btn`. Component-Edits dafür sind erlaubt (in ahmed.md geregelt).
|
||||||
|
|
||||||
|
### 5.3 CI
|
||||||
|
|
||||||
|
- **GitHub Actions** `.github/workflows/rebreak-test.yml`:
|
||||||
|
- Job `backend-unit`: `pnpm install` → `pnpm --filter rebreak-backend test` →
|
||||||
|
Coverage-Upload als Artifact. Läuft auf jedem PR.
|
||||||
|
- Job `frontend-unit`: `pnpm --filter rebreak-native test`. PR-only.
|
||||||
|
- Job `maestro-cloud`: nur main-Branch + manuell via `workflow_dispatch`,
|
||||||
|
Maestro-Cloud-Token aus Secrets, gegen Staging-Build.
|
||||||
|
- **Coverage-Threshold (vitest):** Phase 2 weich (40/40), Phase C streng
|
||||||
|
(70 % critical paths in `server/utils/`, `server/api/coach/`, `server/api/auth/`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Konkrete TODOs nach Priorität
|
||||||
|
|
||||||
|
### Hoch (vor TestFlight, Wochenende)
|
||||||
|
|
||||||
|
| # | TODO | Aufwand | Owner |
|
||||||
|
| - | --------------------------------------------------------------------- | ------- | -------------- |
|
||||||
|
| 1 | Maestro CLI auf Mac + Android-Emulator + iOS-Sim installieren | 30 min | User |
|
||||||
|
| 2 | `apps/rebreak-native/.maestro/auth/sign-in.yaml` schreiben | 1 h | Ahmed |
|
||||||
|
| 3 | `.maestro/sos/start.yaml` schreiben (testID's am SOS-Button + LLM-Pill nötig — Edit-Approval) | 1.5 h | Ahmed + UI |
|
||||||
|
| 4 | `.maestro/community/feed-loads.yaml` schreiben | 1 h | Ahmed |
|
||||||
|
| 5 | Postman-Collection nach `backend/tests/postman/` kopieren + Staging-Env | 1 h | Ahmed |
|
||||||
|
| 6 | Manueller Curl-Smoke-Sheet der Top-5-Endpoints + Erwartungs-Status-Codes | 30 min | Ahmed |
|
||||||
|
|
||||||
|
### Mittel (Woche 1-2 nach TestFlight)
|
||||||
|
|
||||||
|
| # | TODO | Aufwand |
|
||||||
|
| -- | ------------------------------------------------------------------- | ------- |
|
||||||
|
| 7 | Vitest setup `backend/` + erste Test `plan-features.test.ts` portiert | 1.5 h |
|
||||||
|
| 8 | Port der 5 weiteren trucko-unit-Tests | 4 h |
|
||||||
|
| 9 | Neuer Test `auth-requireUser.test.ts` (Bug-Replikation diese Woche) | 2 h |
|
||||||
|
| 10 | GitHub Action `rebreak-test.yml` Job `backend-unit` | 1 h |
|
||||||
|
| 11 | Supertest + Integration-Setup, dann `auth/login.integration.test.ts` | 2 h |
|
||||||
|
| 12 | jest-expo + `@testing-library/react-native` setup im `apps/rebreak-native` | 1 h |
|
||||||
|
| 13 | Erster RN-Test: `lib/llmProvider.test.ts` (Plan-Tier-Resolve-Logik) | 1.5 h |
|
||||||
|
| 14 | Erster Component-Test: `components/profile/ProfileHeader.test.tsx` | 1.5 h |
|
||||||
|
|
||||||
|
### Niedrig (Woche 3+)
|
||||||
|
|
||||||
|
| # | TODO | Aufwand |
|
||||||
|
| -- | ------------------------------------------------------------------- | ------- |
|
||||||
|
| 15 | Lyra-Eval-Suite (Crisis-Detection Golden-Prompts) aus trucko porten | 4 h |
|
||||||
|
| 16 | Maestro-Flow für Phase-C Demographics + Pro-Trial-Reward | 2 h |
|
||||||
|
| 17 | Stripe-Webhook-Test mit Stripe-Mock + Test-Mode | 3 h |
|
||||||
|
| 18 | DSFA-Anhang: Test-Coverage-Statement mit Hans-Müller abstimmen | 1 h |
|
||||||
|
| 19 | Penetrationstest-Provider auswählen (DiGA-Pflicht, jährlich) | User-Decision |
|
||||||
|
| 20 | Coverage-Threshold in CI-Gate hochziehen auf 70 % critical paths | 1 h |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Wo der Schuh am stärksten drückt — TL;DR
|
||||||
|
|
||||||
|
1. **Auth-Middleware ungeschützt.** Der 500-Cascade dieser Woche war ein Lehrgeld-Bug.
|
||||||
|
Ein 50-Zeilen-Vitest-File hätte ihn vor Deploy gefangen. Höchste Priorität.
|
||||||
|
2. **SOS-Stream + Tier-LLM-Routing ungeschützt.** Wenn ein Free-User den falschen
|
||||||
|
LLM bekommt oder der Stream bricht ab in der Krise — wir verlieren Vertrauen
|
||||||
|
plus das ist ein DiGA-Recovery-Risk.
|
||||||
|
3. **Anonymität-Pattern ungeschützt.** Wenn jemand bei einem Refactor versehentlich
|
||||||
|
`select: { firstName: true }` schreibt statt `{ nickname: true }`, sehen plötzlich
|
||||||
|
alle User echte Namen. DSGVO Art. 9 + Stigma + DiGA = sofortige Krise.
|
||||||
|
4. **Pro-Trial-Reward kommt ohne Tests** — Stripe-Revenue-Leak-Risiko, Phase C ist
|
||||||
|
Pflicht-TDD-Zone.
|
||||||
|
5. **Kein CI-Gate.** Jeder Push kann grün durchdeployen, auch wenn die Codebase
|
||||||
|
tot ist. Das muss als erstes nach TestFlight kommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open Questions an User
|
||||||
|
|
||||||
|
1. **Maestro Cloud Account oder lokale Runs?** Cloud kostet ~10 EUR/Monat, gibt
|
||||||
|
parallele iOS+Android-Runs in CI. Lokal ist gratis aber blockt Mac-CPU.
|
||||||
|
2. **Pen-Test-Provider für DiGA-Pflicht?** Jährlich, ca. 5-15k EUR. Ahmed kann
|
||||||
|
Vorschläge sammeln (Cure53, SektionEins, CrowdStrike-Partner) — User-Decision.
|
||||||
|
3. **Demographics + Pro-Trial: TDD oder Test-after?** Empfehlung TDD, weil
|
||||||
|
Reward-Logik in Stripe + DSGVO-relevante Pfade. Bestätigung gewünscht?
|
||||||
|
4. **Coverage-Threshold-Strategie:** soft (warning, kein Block) oder hard
|
||||||
|
(PR-Block bei Drop)? Empfehlung: erste 6 Wochen soft, danach hard auf 60 %.
|
||||||
|
5. **Hans-Müller-Frage:** Soll die DSFA explizit eine Test-Coverage-Quote für
|
||||||
|
DiGA-Antrag committen? Wenn ja — dann müssen wir vor BfArM-Submission
|
||||||
|
beweisbar sein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Ende. — Ahmed
|
||||||
265
ops/UI_MIGRATION_PLAN.md
Normal file
265
ops/UI_MIGRATION_PLAN.md
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
# UI Migration Plan — Settings + Profile (Nuxt → rebreak-native)
|
||||||
|
|
||||||
|
Stand: 2026-05-07
|
||||||
|
Scope: rebreak-native (Expo / RN). Owner: rebreak-native-ui.
|
||||||
|
|
||||||
|
Phase 1 = NUR Plan (dieses Dokument). Keine Code-Änderungen in `apps/rebreak-native/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Status Quo
|
||||||
|
|
||||||
|
### Nuxt-App (`~/mono/trucko-monorepo/apps/rebreak/app/pages/`)
|
||||||
|
|
||||||
|
Bereits gebaute Pages, die in rebreak-native fehlen oder nur als Stub existieren:
|
||||||
|
|
||||||
|
- `app/settings.vue` (~520 LOC) — 3 UTabs: **Streak**, **Profil**, **Einstellungen**
|
||||||
|
- Streak: `<StreakTab />` (separate Component)
|
||||||
|
- Profil: nickname-Edit + Avatar (HERO_AVATARS preset ODER Foto-Upload mit Cropper)
|
||||||
|
- Einstellungen-Tab enthält:
|
||||||
|
- Hilfe & FAQ (Link)
|
||||||
|
- Appearance: system / light / dark (UColorMode)
|
||||||
|
- Language: i18n locale-switch
|
||||||
|
- Dev-Tools: Keyboard-Resize-Test, Family-Controls Spike
|
||||||
|
- **Devices** (`useDeviceStore`) — Liste mit Limit-Progressbar, current-device-Badge
|
||||||
|
- **Community-Domains** (approved + rejected) aus `/api/custom-domains`
|
||||||
|
- **Subscription** (Stripe Portal — `/api/stripe/portal`) — paid only
|
||||||
|
- Logout
|
||||||
|
- `app/profile/[userId].vue` — fremdes Profil ansehen (nickname, tier, posts, follow, recent posts, DM-link)
|
||||||
|
|
||||||
|
### rebreak-native — was schon existiert
|
||||||
|
|
||||||
|
- `app/settings.tsx` (223 LOC) — Stub-Scaffold. Sections vorhanden, aber Handlers leer (`onPress: () => {}`).
|
||||||
|
- `components/AppHeader.tsx` — Dropdown-Menu funktioniert, hat schon `editProfile` + `settings` Items, beide routen auf `/settings`. SOS-Button im Dropdown ist auch da.
|
||||||
|
- `app/urge.tsx` — SOS-Page mit `KeyboardAvoidingView` + `<TtsProviderToggle />` + `<LlmProviderToggle />` als Floating-Bar (`st.ttsToggleBar`, line 1128–1131).
|
||||||
|
- `lib/ttsProvider.ts` — AsyncStorage-Persist, 5 provider (`openai`, `gemini`, `google-cloud`, `elevenlabs`, `cartesia`).
|
||||||
|
- `lib/llmProvider.ts` — analog für LLM (`auto`, `openrouter-sonnet`, …).
|
||||||
|
|
||||||
|
### Was komplett fehlt im rebreak-native
|
||||||
|
|
||||||
|
- Profile-Edit-Logik (nickname-Form, Avatar-Picker mit HERO_AVATARS + Photo-Upload)
|
||||||
|
- StreakTab-Equivalent als RN-Component
|
||||||
|
- Devices-Section (read aus `/api/devices`)
|
||||||
|
- Community-Domains-Section
|
||||||
|
- Stripe-Portal-Button
|
||||||
|
- Theme/Language-Switcher mit echtem State (i18n + ColorMode)
|
||||||
|
- Lyra-Voice-Picker (existiert nirgends)
|
||||||
|
- Debug-Section (TtsProviderToggle + LlmProviderToggle aktuell hardcoded auf urge.tsx)
|
||||||
|
- Profile-View für fremde User (`/app/profile/[userId]` Nuxt → `/profile/[userId].tsx` RN)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Header-Dropdown-Menu Architektur
|
||||||
|
|
||||||
|
Bereits im File: `apps/rebreak-native/components/AppHeader.tsx:65–76`.
|
||||||
|
|
||||||
|
Aktuell:
|
||||||
|
```
|
||||||
|
[SOS — heart, prominent]
|
||||||
|
─────
|
||||||
|
person-outline "Profil bearbeiten" → /settings
|
||||||
|
settings-outline "Einstellungen" → /settings
|
||||||
|
─────
|
||||||
|
log-out-outline "Abmelden"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empfohlene Items nach Migration:**
|
||||||
|
- SOS (bleibt prominent)
|
||||||
|
- Profil bearbeiten → `/settings?tab=profile`
|
||||||
|
- Einstellungen → `/settings?tab=settings`
|
||||||
|
- Streak → `/settings?tab=streak`
|
||||||
|
- (Legend-only) Lyra-Voice → `/settings?tab=settings#lyra-voice`
|
||||||
|
- (Dev-Builds only / `__DEV__`) Debug → `/settings?tab=debug`
|
||||||
|
- Logout
|
||||||
|
|
||||||
|
→ Settings-Page übernimmt die alte Nuxt-3-Tab-Struktur **plus** Debug-Tab (gated auf `__DEV__` oder Internal-User).
|
||||||
|
|
||||||
|
Kein neues Header-Element nötig — Dropdown ist da, nur die `tab`-Query-Param-Logik in `settings.tsx` ergänzen + Items ergänzen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Settings-Page Struktur (Migration-Ziel)
|
||||||
|
|
||||||
|
`app/settings.tsx` umbauen zu Tab-Layout (z.B. via simple Tab-Bar — kein UTabs in RN, custom mit drei `Pressable`-Buttons reicht).
|
||||||
|
|
||||||
|
### Tab 1 — Streak (MVP)
|
||||||
|
- StreakBadge + currentDays/longestDays (read aus `/api/streak`)
|
||||||
|
- Heatmap der letzten Wochen (StreakEvent-Liste)
|
||||||
|
- Reset-Button mit Confirm-Modal
|
||||||
|
|
||||||
|
### Tab 2 — Profil (MVP)
|
||||||
|
- Avatar (HERO_AVATARS preset grid + Photo-Upload via `expo-image-picker`)
|
||||||
|
- Nickname-Input (Save → `PATCH /api/auth/me { nickname }`)
|
||||||
|
- Username (read-only display, `@username`)
|
||||||
|
- Member-Since-Date
|
||||||
|
|
||||||
|
### Tab 3 — Einstellungen (MVP)
|
||||||
|
- **Appearance** — system/light/dark (AsyncStorage + theme-Provider) — *requires theme-store create*
|
||||||
|
- **Language** — de/en (i18n.changeLanguage + AsyncStorage persist)
|
||||||
|
- **Push-Notifications** Toggle (existiert schon im Stub)
|
||||||
|
- **Streak-Reminders** Toggle
|
||||||
|
- **Devices** (read aus `/api/devices`) — Liste, kein Delete (cooldown noch nicht implementiert)
|
||||||
|
- **Subscription** — Plan-Status + "Manage" → opens Stripe-Portal-URL via `Linking.openURL(url)`
|
||||||
|
- **Logout**
|
||||||
|
- **Delete Account** (Danger-Zone)
|
||||||
|
|
||||||
|
### Tab 4 — Lyra (Legend-only)
|
||||||
|
- Voice-Picker (default: Alexandra ElevenLabs / Sonic Cartesia)
|
||||||
|
- Speed-Slider? (ElevenLabs `voice_settings.style` o.ä.)
|
||||||
|
- Preview-Button (sample-text TTS)
|
||||||
|
|
||||||
|
### Tab 5 — Debug (gated `__DEV__` || internal-user)
|
||||||
|
- TtsProviderToggle (verschoben aus urge.tsx)
|
||||||
|
- LlmProviderToggle (verschoben aus urge.tsx)
|
||||||
|
- Bench-Anzeige (letzte BenchSession-Werte aus `lib/sosTtsBenchmark`)
|
||||||
|
- Reset-AsyncStorage-Button
|
||||||
|
|
||||||
|
### Followup (NICHT MVP)
|
||||||
|
- Community-Domains-Liste (kann zur Blocker-Page wandern)
|
||||||
|
- Hilfe & FAQ — Webview oder externe URL erstmal
|
||||||
|
- Family-Controls Spike — Native-only, kein UI nötig
|
||||||
|
- Profile-View `/profile/[userId].tsx` — Community-Section, kommt mit Community-Migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. iOS-Keyboard-Fix für SOS-Page
|
||||||
|
|
||||||
|
### Pattern aus `components/PostCommentsSheet.tsx`
|
||||||
|
|
||||||
|
Funktionierende Bestandteile (line 126–139, 240, 367):
|
||||||
|
|
||||||
|
1. **Listener** auf `keyboardWillShow`/`keyboardWillHide` (iOS) bzw. `keyboardDidShow`/`keyboardDidHide` (Android) → `keyboardHeight`-State.
|
||||||
|
2. **Container-Padding bottom = `Platform.OS === 'ios' ? keyboardHeight : 0`** auf der gesamten inneren Inhalts-View. (Android nutzt `windowSoftInputMode=adjustResize` und braucht kein Padding.)
|
||||||
|
3. **Input-Bar paddingBottom = `keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom)`** — schlankes Padding bei offener Tastatur, sonst Safe-Area.
|
||||||
|
4. `keyboardShouldPersistTaps="handled"` auf der scrollbaren Liste.
|
||||||
|
|
||||||
|
### Was urge.tsx aktuell macht (line 1144)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={0}>
|
||||||
|
```
|
||||||
|
|
||||||
|
→ funktioniert in Modals manchmal, aber bei Full-Screen-Pages wie urge.tsx (kein Modal!) overlapped der Input bei iOS, weil `behavior="padding"` kämpft mit der SafeAreaView und dem festen `paddingTop: insets.top`. User-Bug ist reproduziert.
|
||||||
|
|
||||||
|
### Vorgeschlagener Fix für `app/urge.tsx`
|
||||||
|
|
||||||
|
**Option A (empfohlen, 1:1 PostCommentsSheet-Pattern):**
|
||||||
|
- `KeyboardAvoidingView` **entfernen**.
|
||||||
|
- `keyboardHeight`-State (existiert schon ab line 92, line 198–199 listener → behalten).
|
||||||
|
- Auf den äußeren Container (oder den Wrapper um `FlatList + chips + inputBar`) `paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0` anwenden.
|
||||||
|
- Input-Bar (line 1230): `paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom)` ist schon korrekt — bleibt.
|
||||||
|
- `FlatList`: `keyboardShouldPersistTaps="handled"` hinzufügen (verhindert dass tap-on-chip die Tastatur wegklickt).
|
||||||
|
|
||||||
|
**Option B (minimaler Eingriff):**
|
||||||
|
- `KeyboardAvoidingView`-`keyboardVerticalOffset` auf `insets.top + topBarHeight` setzen statt `0` — hilft bei vielen Layouts, aber fragiler.
|
||||||
|
|
||||||
|
→ **A** ist konsistent mit dem bewährten Pattern und schon benutzt für PostComment. Soll der Default sein.
|
||||||
|
|
||||||
|
### Files anzupassen
|
||||||
|
|
||||||
|
- `apps/rebreak-native/app/urge.tsx` (lines 1144, 1249, ggf. um den top-bar-Container herum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Lyra-Voice-Feature
|
||||||
|
|
||||||
|
### Status backend
|
||||||
|
|
||||||
|
`speak-elevenlabs.post.ts:28` und `speak-cartesia.post.ts:23` lesen voiceId NUR aus `runtimeConfig.elevenlabsVoiceId` / `cartesiaVoiceId` oder aus `process.env`. **Kein body-param `voiceId` aktuell.**
|
||||||
|
|
||||||
|
Aktuell ist die Voice systemweit fix.
|
||||||
|
|
||||||
|
### Frage: DB-Schema-Change nötig?
|
||||||
|
|
||||||
|
**JA** — aber minimal. Variante (a) ist **empfohlen**:
|
||||||
|
|
||||||
|
**(a) Profile-Field erweitern (1 Spalte):**
|
||||||
|
```prisma
|
||||||
|
model Profile {
|
||||||
|
...
|
||||||
|
lyraVoiceId String? @map("lyra_voice_id") // null = system-default
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
+ Migration: `ALTER TABLE rebreak.profiles ADD COLUMN lyra_voice_id text NULL;`
|
||||||
|
+ `me.patch.ts` um `lyraVoiceId` erweitern (gated: nur Legend-User dürfen setzen).
|
||||||
|
+ `me.get.ts` returnt `lyraVoiceId` mit.
|
||||||
|
|
||||||
|
**(b) Separates `UserPreference`-Modell (overkill für jetzt):**
|
||||||
|
Nur lohnenswert wenn weitere Prefs (theme, language-override, push-prefs) bald hinzu kommen. Dann lieber zentral.
|
||||||
|
|
||||||
|
→ Empfehlung: **(a)** für Phase 2, **(b)** parken bis 3+ Prefs gleichzeitig nötig sind.
|
||||||
|
|
||||||
|
### API-Changes
|
||||||
|
|
||||||
|
`speak-elevenlabs.post.ts` + `speak-cartesia.post.ts`:
|
||||||
|
- Body um optional `voiceId?: string` erweitern.
|
||||||
|
- VoiceId-Resolve-Order: body-param → user.lyraVoiceId (DB) → runtimeConfig → env → FALLBACK.
|
||||||
|
- Tier-check: wenn body-param gesetzt aber user nicht Legend → 403 oder silent-ignore + use default (silent-ignore safer).
|
||||||
|
|
||||||
|
### Frontend-Flow
|
||||||
|
|
||||||
|
1. `useMe()` returned bereits Profile incl. zukünftiges `lyraVoiceId`.
|
||||||
|
2. Settings-Tab (Lyra) — Voice-Liste hardcoded zur First-Iteration:
|
||||||
|
- ElevenLabs: Alexandra (`kdmDKE6EkgrWrrykO9Qt`), Rachel, …
|
||||||
|
- Cartesia: Default-DE (`b9de4a89-2257-424b-94c2-db18ba68c81a`), …
|
||||||
|
3. Save → `PATCH /api/auth/me { lyraVoiceId }`.
|
||||||
|
4. `lib/sosTtsQueue.ts:208` — Body um `voiceId: useMe().lyraVoiceId` erweitern.
|
||||||
|
|
||||||
|
### Voices initial (nice-to-have hardcoded)
|
||||||
|
|
||||||
|
- ElevenLabs: 3–5 deutsche female voices auswählen, in `lib/lyraVoices.ts` als statische Liste.
|
||||||
|
- Cartesia: 2–3 deutsche stimmen.
|
||||||
|
- Per Provider (TTS-Provider != Voice-Provider), Voice-Picker zeigt nur Voices passend zum gewählten TTS-Provider — oder "auto" mit per-Provider-Default-Map.
|
||||||
|
|
||||||
|
→ Erste Iteration: nur ElevenLabs-Voices anbieten (häufigster Provider), andere Provider ignorieren `lyraVoiceId`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Migration-Reihenfolge (Phase 2/3/4)
|
||||||
|
|
||||||
|
### Phase 2 — Quick Wins (1–2 days)
|
||||||
|
1. **iOS-Keyboard-Fix urge.tsx** — kein Server-Change, isolierter UI-Fix → Smoke-Test auf iOS-Device.
|
||||||
|
2. **Debug-Tab in settings.tsx** — TtsProviderToggle + LlmProviderToggle dort einbauen, aus urge.tsx entfernen.
|
||||||
|
3. **Header-Dropdown** — Items "Streak" + "Debug" (gated `__DEV__`) ergänzen, settings.tsx Tab-Routing implementieren.
|
||||||
|
|
||||||
|
### Phase 3 — MVP-Cutover (3–5 days)
|
||||||
|
4. **Profil-Tab** — nickname + Avatar-Picker (preset). Photo-Upload via `expo-image-picker` + Crop später (sehr großes File mit `vue-advanced-cropper`-Equivalent in RN: `react-native-image-crop-picker` wäre Native-Module — später).
|
||||||
|
5. **Streak-Tab** — useStreak-Hook + Badge.
|
||||||
|
6. **Einstellungen-Tab** — Devices-Liste, Theme/Language-Picker, Notification-Toggles (real persisten), Logout (existiert).
|
||||||
|
7. **Subscription-Section** — Stripe-Portal-Link via `Linking.openURL`.
|
||||||
|
|
||||||
|
### Phase 4 — Legend Features (2–3 days)
|
||||||
|
8. **DB-Migration** `lyra_voice_id` (Backend-team).
|
||||||
|
9. **API-Update** `speak-*.post.ts` body-param + tier-check.
|
||||||
|
10. **Voice-Picker UI** im Lyra-Tab.
|
||||||
|
11. **sosTtsQueue.ts** body voiceId.
|
||||||
|
|
||||||
|
### Phase 5 — Followup
|
||||||
|
12. Community-Domains-Section (mit Blocker-Migration zusammen).
|
||||||
|
13. Profile-View `/profile/[userId].tsx`.
|
||||||
|
14. Hilfe/FAQ-Section.
|
||||||
|
15. Photo-Upload mit Crop (native module).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Top-3 Risiken
|
||||||
|
|
||||||
|
1. **Avatar-Photo-Upload** — Nuxt nutzt `vue-advanced-cropper` (HTML5-Canvas). RN-Alternative: `react-native-image-crop-picker` ist ein Native-Module (Expo-Plugin nötig) → Coordination mit `zied`/`backyard`. **Mitigation:** MVP nur HERO_AVATARS preset, photo-upload als Phase-5.
|
||||||
|
2. **Theme-Switch / ColorMode** — Aktuell nutzt rebreak-native NICHT `useColorScheme`-getriebenes Theme. Komplette Color-Token-Refactor wäre nötig (`lib/theme.ts` aktuell hardcoded light). **Mitigation:** MVP "system" disabled, nur Lock auf light. Dark-Mode Phase-5.
|
||||||
|
3. **DB-Migration `lyra_voice_id`** — Schema-Change auf production via Prisma migration → wenn Cutover-Blocker noch nicht resolved (siehe `feedback_backend_runtime_config.md`), hängt das. **Mitigation:** Voice-Picker erst bauen wenn Cutover stabil; bis dahin client-side AsyncStorage als Mock-Persist (nur lokal, regelt sich nach Login auf neuem Gerät).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Empfehlung — erster Schritt in Phase 2
|
||||||
|
|
||||||
|
**Start: iOS-Keyboard-Fix in `app/urge.tsx`.**
|
||||||
|
|
||||||
|
Warum:
|
||||||
|
- Isoliert (1 File, kein Backend, keine Coordination)
|
||||||
|
- Direkt user-sichtbar als Win
|
||||||
|
- Pattern (PostCommentsSheet) bereits validiert
|
||||||
|
- Kein Risiko für andere Features (kein Schema, kein API-Change)
|
||||||
|
- Sets up den Workflow: small PR, smoke-test, merge — vor den größeren Settings-Migrations.
|
||||||
|
|
||||||
|
Danach: Debug-Tab + TtsProviderToggle/LlmProviderToggle aus urge.tsx in settings.tsx wandern (entfernt visuelle Bench-Bar aus Production-Build, optional gated auf `__DEV__`).
|
||||||
177
ops/WEBHOOK_MIGRATION_PLAN.md
Normal file
177
ops/WEBHOOK_MIGRATION_PLAN.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# Webhook-Migration: Standalone Listener → Nitro-Endpoint (Trucko-Pattern)
|
||||||
|
|
||||||
|
**Owner:** Backyard
|
||||||
|
**Erstellt:** 2026-05-07
|
||||||
|
**Status:** PLAN — keine Implementierung in dieser Session. User-Direktive: „langsam starten".
|
||||||
|
**Ziel:** Migration des GitHub-Webhook-Receivers vom standalone `scripts/deploy-webhook/server.mjs` zu einem Nitro-Endpoint im Backend (`backend/server/api/webhook/github.post.ts`) — analog zum Trucko-Monorepo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Status quo (Stand 2026-05-07)
|
||||||
|
|
||||||
|
### 1.1 Aktuelle Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
GitHub push → nginx (staging.rebreak.org/webhook)
|
||||||
|
→ 127.0.0.1:9000 (pm2: rebreak-webhook)
|
||||||
|
→ scripts/deploy-webhook/server.mjs (HMAC-Verify + Queue)
|
||||||
|
→ spawn bash scripts/deploy.sh
|
||||||
|
→ git fetch + reset --hard origin/main
|
||||||
|
→ pnpm install --frozen-lockfile (workspace-root)
|
||||||
|
→ cd backend && pnpm --filter rebreak-backend build
|
||||||
|
→ .output → .output-staging (atomisch via tmp)
|
||||||
|
→ pm2 restart rebreak-staging --update-env
|
||||||
|
→ pm2 restart rebreak-imap-staging / rebreak-idle-staging / dns-* (best-effort)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Beteiligte Dateien
|
||||||
|
|
||||||
|
| Datei | Rolle |
|
||||||
|
|------|-------|
|
||||||
|
| `/srv/rebreak/scripts/deploy-webhook/server.mjs` | Standalone Node-HTTP-Server auf Port 9000, HMAC-Verify, spawn deploy.sh |
|
||||||
|
| `/srv/rebreak/scripts/deploy.sh` | Pull → install → build → atomic-deploy → pm2 restart |
|
||||||
|
| `/srv/rebreak/ecosystem.config.js` | pm2-Service `rebreak-webhook` (cluster mode, 1 instance) |
|
||||||
|
| `/etc/nginx/sites-enabled/staging.rebreak.org` | `location /webhook` → `proxy_pass http://127.0.0.1:9000/webhook` |
|
||||||
|
| `/etc/environment` | `GITHUB_WEBHOOK_SECRET` (via Infisical-bootstrap), `INFISICAL_CLIENT_ID/SECRET` |
|
||||||
|
|
||||||
|
### 1.3 Beobachtete Probleme
|
||||||
|
|
||||||
|
1. **Push-Detection unzuverlässig:** Manche Pushes triggern Deploy nicht (Listener empfängt Event nicht oder schweigt) — Symptom unklar (timing/network/hängender vorheriger spawn?).
|
||||||
|
2. **deploy.sh exit code 1:** Vergangene Logs zeigen `cd apps/rebreak: No such file or directory` (alter Pre-Cutover-Pfad in alten Logs; aktueller deploy.sh ist auf `backend/` korrekt umgestellt — Symptom nicht mehr reproduzierbar, aber Vertrauensverlust).
|
||||||
|
3. **Kein Healthcheck:** `/webhook` antwortet nur auf POST mit valider Sig. Kein `GET /webhook/health` o.ä. — manuelles Debug nur via `pm2 logs rebreak-webhook`.
|
||||||
|
4. **Kein Retry:** Wenn `git fetch` netzwerkbedingt failt, bleibt Deploy hängen / aborts ohne Rescheduling.
|
||||||
|
5. **Logs verstreut:** Webhook-Logs in `pm2 logs rebreak-webhook`, Deploy-Output in dieselben Logs gestreamt aber mit `[deploy] /[deploy:err]`-Prefixen — kein strukturiertes Logging, kein File-Persistence über pm2-Restart hinweg.
|
||||||
|
6. **Single-Point-of-Failure:** Wenn `rebreak-webhook` pm2-Prozess crashed, gehen Pushes ins Leere — keine GitHub-Redelivery-Automatik (manuell via GitHub UI nötig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Trucko-Pattern (das Vorbild)
|
||||||
|
|
||||||
|
**Quelldatei:** `~/mono/trucko-monorepo/backend/server/api/webhook/github.post.ts` (957 Zeilen)
|
||||||
|
**Test-Coverage:** `~/mono/trucko-monorepo/backend/tests/unit/webhook-hmac.test.ts` (HMAC-Verify isoliert testbar)
|
||||||
|
|
||||||
|
### 2.1 Architektur-Eigenschaften
|
||||||
|
|
||||||
|
- **Webhook ist ein Nitro-API-Endpoint im Backend selbst** — kein separater Listener.
|
||||||
|
Pfad: `backend/server/api/webhook/github.post.ts` → wird über das Backend-pm2 (`rebreak-staging`) bedient.
|
||||||
|
- **HMAC-Sig-Verify** identisch wie unsere `server.mjs` (sha256, `timingSafeEqual`).
|
||||||
|
- **Reads `useRuntimeConfig()`** für `GITHUB_WEBHOOK_SECRET`, `GITHUB_USERNAME`, `GITHUB_TOKEN` — Secrets kommen via Infisical → Nuxt/Nitro runtimeConfig (kein extra `/etc/environment`-Parsing).
|
||||||
|
- **Affected-Detection:** Liest `payload.commits[].added/modified/removed`, normalisiert Pfade, mapped auf eine Tabelle `affected = { rebreak: bool, backend: bool, nginx: bool, ... }`. Nur betroffene Apps werden gebaut.
|
||||||
|
- **Queue-Mechanismus mit Merge:** `queueDeployment()` mergt `affected`-Flags wenn ein zweiter Push während eines laufenden Builds reinkommt → kein Doppel-Build, aber kein Verlust.
|
||||||
|
- **Inline-Build-Steps via `executeCommand()`-Helper:** Jeder Step (git fetch / install / build / pm2 restart) ist ein einzelner spawned bash mit Timeout + structured stdout/stderr-Capture. Kein externes deploy.sh.
|
||||||
|
- **Token-basierter Pull:** Nutzt `GITHUB_USERNAME` + `GITHUB_TOKEN` für `https://...@github.com/...` — kein SSH-Deploy-Key-Handling im Webhook.
|
||||||
|
- **Atomisches Output-Swap** für rebreak: `cp -r .output .output-staging-new && rm -rf .output-staging && mv .output-staging-new .output-staging` (identisch zu unserem deploy.sh).
|
||||||
|
- **Logs in pm2-stream:** Alles via `console.log("[Webhook] ...")` — landet in `pm2 logs backend`, zentral.
|
||||||
|
- **Healthcheck implizit:** Wenn das Backend lebt, lebt der Webhook. pm2 zeigt `backend online` → Webhook funktioniert.
|
||||||
|
|
||||||
|
### 2.2 Was das Trucko-Pattern besser macht
|
||||||
|
|
||||||
|
| Aspekt | Standalone-Listener (Status quo) | Nitro-Endpoint (Trucko) |
|
||||||
|
|--------|-----------------------------------|-------------------------|
|
||||||
|
| Single Source of Truth | 2 pm2-Services (webhook + app) | 1 pm2-Service (Backend allein) |
|
||||||
|
| Secret-Handling | `/etc/environment` parsing manuell | `useRuntimeConfig()` via Infisical |
|
||||||
|
| Affected-Logik | Nicht vorhanden, baut immer alles | Granular, nur betroffene Apps |
|
||||||
|
| Logs | 2 verschiedene pm2-Streams | 1 zentraler Stream |
|
||||||
|
| Test-Coverage | Keine Tests | `webhook-hmac.test.ts` (vitest) |
|
||||||
|
| Code-Lokation | `scripts/` (separater Code-Pfad) | `backend/server/api/` (gleiche TS-Codebase) |
|
||||||
|
| Crash-Resilience | Eigener Prozess kann sterben ohne app | Wenn Backend lebt, lebt Webhook |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Migrations-Schritte (für nächste Session — NICHT jetzt)
|
||||||
|
|
||||||
|
### Phase 1: Endpoint anlegen (Mac, kein Server-Change)
|
||||||
|
|
||||||
|
1. Datei `backend/server/api/webhook/github.post.ts` erstellen — kopiere Trucko-Vorlage, passe an:
|
||||||
|
- Repo-Pfad: `/srv/rebreak` (statt `/srv/trucko-monorepo`)
|
||||||
|
- `affected`-Map: nur `rebreak-native`, `backend`, `nginx`, `dns`, `imap` (kein `pizzabox`/`driver`/etc.)
|
||||||
|
- Build-Steps: nur Backend-Build (`pnpm --filter rebreak-backend build`) + atomic-output-swap + pm2 restart `rebreak-staging`
|
||||||
|
- Entferne Trucko-spezifische nginx-conf-Listen (die unsere ops/nginx-Files haben andere Namen)
|
||||||
|
2. `nitro.config.ts`: `runtimeConfig` um `githubWebhookSecret`, `githubUsername`, `githubToken` ergänzen (analog Trucko).
|
||||||
|
3. Test-File `backend/tests/unit/webhook-hmac.test.ts` mitmigrieren (vitest setup falls nötig).
|
||||||
|
4. Lokal `pnpm --filter rebreak-backend build` → checken dass Endpoint im `.output/server/chunks/routes/api/webhook/github.post.mjs` landet.
|
||||||
|
|
||||||
|
### Phase 2: Infisical-Secrets setzen (User-Eskalation)
|
||||||
|
|
||||||
|
5. Infisical Project rebreak-staging:
|
||||||
|
- `GITHUB_WEBHOOK_SECRET` (bereits vorhanden in /etc/environment, in Infisical spiegeln)
|
||||||
|
- `GITHUB_USERNAME` (z.B. `chahinebrini`)
|
||||||
|
- `GITHUB_TOKEN` (PAT mit `repo`-Scope für git-pull über HTTPS)
|
||||||
|
→ diese Secrets werden via `start-staging.sh` als `NUXT_*` env vars dem Backend bereitgestellt.
|
||||||
|
|
||||||
|
### Phase 3: Parallel-Betrieb (Failsafe)
|
||||||
|
|
||||||
|
6. **Beide Endpoints aktiv halten:**
|
||||||
|
- GitHub-Webhook bleibt auf `https://staging.rebreak.org/webhook` (alter Listener auf Port 9000).
|
||||||
|
- Neuer Endpoint zusätzlich erreichbar unter `https://staging.rebreak.org/api/webhook/github` (Backend-Routing).
|
||||||
|
7. Auf GitHub: **zweite Webhook-Konfig** anlegen, beide aktiv. Beide verifizieren mit demselben Secret.
|
||||||
|
8. Logs beide Streams beobachten — wenn neuer Endpoint zuverlässig deployt, alter wird redundant.
|
||||||
|
|
||||||
|
### Phase 4: Cutover
|
||||||
|
|
||||||
|
9. GitHub-Webhook umstellen: alten URL-Webhook deaktivieren, nur neuer `/api/webhook/github` aktiv.
|
||||||
|
10. nginx: `location /webhook` → `proxy_pass http://127.0.0.1:9000` ENTFERNEN (oder auf Backend umrouten).
|
||||||
|
11. `pm2 stop rebreak-webhook && pm2 delete rebreak-webhook` (NUR auf User-Approval — destruktiv).
|
||||||
|
12. `scripts/deploy-webhook/` archivieren (nicht löschen — Reference).
|
||||||
|
13. `scripts/deploy.sh` entweder:
|
||||||
|
- **a)** behalten als Fallback-Tool für manuelles `bash deploy.sh` auf SSH (empfohlen), oder
|
||||||
|
- **b)** Code-Logik ins Backend-Endpoint inline-spawnen wie Trucko es tut (mehr Code, aber 100% Single-Source).
|
||||||
|
|
||||||
|
### Phase 5: Hardening
|
||||||
|
|
||||||
|
14. Healthcheck-Endpoint hinzufügen: `backend/server/api/webhook/health.get.ts` → returns `{ ok, lastDeploy, queueDepth }`.
|
||||||
|
15. Persistent Deploy-Log: in `/var/log/rebreak/deploys.jsonl` schreiben (statt nur pm2-stream).
|
||||||
|
16. Slack/Discord-Notifier on deploy-fail (optional).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Risk-Assessment
|
||||||
|
|
||||||
|
### Top 3 Risks bei Migration
|
||||||
|
|
||||||
|
1. **Self-Deploy-Loop / Race-Condition beim Restart:**
|
||||||
|
Der Webhook lebt im Backend-Prozess. Wenn der Webhook `pm2 restart rebreak-staging` aufruft, killt der Prozess sich selbst, BEVOR die HTTP-Response gesendet ist. → GitHub bekommt timeout, retry-storm. **Mitigation:** Trucko macht's auch, und es funktioniert dort. Schlüssel: pm2 restart ist async + `setTimeout(2000)` vor dem restart, response wurde schon vor Build-Start gesendet (`return { ok }` BEVOR `queueDeployment()` läuft im Background). Muss bei uns **gleich** strukturiert werden.
|
||||||
|
|
||||||
|
2. **Build-Failure crashed Webhook-Capability:**
|
||||||
|
Wenn ein Bad-Push das Backend-Build kaputt macht, hat der nächste Push keinen Webhook-Endpoint mehr (Backend offline). → Stuck-State, manuelles SSH nötig zum Recovery.
|
||||||
|
**Mitigation:** Atomic-Output-Swap (.output → .output-staging via mv) bleibt. Alter Build überlebt im `.output-staging`, Backend bleibt online auch wenn neuer Build failed. **PLUS:** Listener als Failsafe parallel erstmal behalten (Phase 3).
|
||||||
|
|
||||||
|
3. **Infisical-Secret-Loading-Lücke:**
|
||||||
|
Wenn `useRuntimeConfig()` zur Build-Zeit aufgerufen wird (statt Runtime), landen die Secrets nicht im Endpoint. Trucko nutzt `runtimeConfig` → wird per `NUXT_*`-env bei Start aufgelöst. Unsere `start-staging.sh` muss `NUXT_GITHUB_WEBHOOK_SECRET`, `NUXT_GITHUB_USERNAME`, `NUXT_GITHUB_TOKEN` setzen — sonst 401 für jeden Push.
|
||||||
|
**Mitigation:** Phase 2 explizit verifizieren via `curl https://staging.rebreak.org/api/webhook/github -X POST -H "x-hub-signature-256: sha256=..." -d '...'` mit Test-Payload BEVOR GitHub-Webhook umgestellt wird.
|
||||||
|
|
||||||
|
### Weitere Risiken (lower-priority)
|
||||||
|
|
||||||
|
- **GitHub-Token-Lifetime:** PAT läuft ab → silent fail. Mitigation: Token mit langer Lifetime, ggf. Rotation-Reminder.
|
||||||
|
- **Body-Parsing-Reihenfolge:** Nitro liest body normal als JSON, aber HMAC braucht raw-body. Trucko liest manuell `event.req.on("data")` — Nitro-Middleware darf body nicht vorher konsumiert haben. Ggf. Plugin-Order checken.
|
||||||
|
- **OOM während Build:** 4 GB RAM, `NODE_OPTIONS=--max-old-space-size=1536` reicht heute. Wenn Backend-Build wächst → OOM-kill mid-build → nicht-atomar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Fallback-Plan
|
||||||
|
|
||||||
|
Wenn Migration scheitert:
|
||||||
|
|
||||||
|
1. **Listener bleibt aktiv** während Migration (Phase 3 ist parallel-Betrieb).
|
||||||
|
2. **Rollback-Path:**
|
||||||
|
- GitHub-Webhook URL zurück auf `/webhook` (alter Listener).
|
||||||
|
- `pm2 restart rebreak-webhook` (falls gestoppt).
|
||||||
|
- Neuer Endpoint im Backend bleibt vorhanden, antwortet aber nicht mehr aus GitHub-Pushes — kein Schaden.
|
||||||
|
3. **Total-Rollback (nur falls Backend-Endpoint Backend-Builds blockiert):**
|
||||||
|
- File `backend/server/api/webhook/github.post.ts` löschen, neu builden, deployen.
|
||||||
|
- Cleanup: `runtimeConfig`-Einträge in `nitro.config.ts` zurückrollen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Empfohlener erster Schritt (für nächste Session)
|
||||||
|
|
||||||
|
**Read-only Investigation:** Prüfen welche Infisical-Secrets aktuell im Backend-Runtime verfügbar sind (`pm2 logs rebreak-staging | grep -i webhook` + Infisical-CLI `infisical secrets --env=staging | grep GITHUB`). Damit ist klar ob Phase 2 trivial ist (Secrets bereits da) oder neue Secrets nötig sind. **Erst danach** Phase 1 (Endpoint anlegen) auf Mac, ohne deployen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Was NICHT in Scope
|
||||||
|
|
||||||
|
- Code-Änderungen am App-Code (Nitro-Routes außerhalb `/webhook/`).
|
||||||
|
- Mail-Stack-Touchups (Mo's Scope).
|
||||||
|
- nginx-Routing-Änderung jetzt — erst nach Cutover-Phase 4.
|
||||||
|
- Force-Push / git history-rewrite.
|
||||||
Loading…
x
Reference in New Issue
Block a user