chahinebrini 3c52d8869e feat(native): WIP checkpoint — Profile/Settings/Demographics + WheelPicker + Maestro
Rollback-Punkt vor Expo SDK 54 / RN 0.81 Upgrade.

UI/UX:
- Profile: ProfileHeader redesign (sign-in chip + member-since), StatsBar 3 pill cards,
  Demographics accordion completed (Geburtsjahr, Geschlecht, Familienstand, Beruf-split,
  Wohnort), Pro-Trial-Banner, Approved-Domains list, DigaMissionBanner
- Settings: section-based layout, neutral icons (matched Header dropdown style)
- Header dropdown: extended with logout + games-page link
- Notifications page: skeleton dummy data
- Locales: i18n keys for new screens

New components:
- WheelPickerModal: native iOS UIPickerView wheel for long lists (Geburtsjahr 91 items,
  Bundesland 16, Stadt 30+/Bundesland)
- OptionsBottomSheet: iOS-style options sheet (used briefly for Geschlecht, currently
  unused — kept for potential future use)
- germanCities.ts: Top-cities per Bundesland (DSGVO-clean static data)

New libs (NewArch-codegen verified):
- @react-native-menu/menu 2.0.0 (UIMenu wrapper, Apple HIG-konform)
- @lodev09/react-native-true-sheet 3.10.1 (UISheetPresentationController wrapper —
  ABER incompatible mit RN 0.79.6, Build-Error → Trigger für SDK-54-Upgrade)

Maestro E2E:
- Initial setup mit auth/community/profile/urge flows

Scripts:
- build-ios-clean.sh: Xcode DerivedData + ios/build cleanup vor expo run:ios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:32:27 +02:00

1072 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
import { View, Text, Pressable, Dimensions, PanResponder, Platform } from 'react-native';
import Svg, { Defs, Pattern, Path, Rect, Polyline, Circle, Line } from 'react-native-svg';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SvgXml } from 'react-native-svg';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics';
import Slider from '@react-native-community/slider';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { memorySvg, snakeSvg, tetrisSvg, tictactoeSvg } from './gameSvgs';
// Haptic helper — fire-and-forget, swallow errors on platforms without taptic engine
function tapHaptic() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
}
function mediumHaptic() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
}
export type GameType = 'memory' | 'tictactoe' | 'snake' | 'tetris';
export const GAME_META: Array<{ id: GameType; svg: string; titleKey: string; descKey: string }> = [
{ id: 'memory', svg: memorySvg, titleKey: 'urge.game_memory', descKey: 'urge.game_memory_desc' },
{ id: 'tictactoe', svg: tictactoeSvg, titleKey: 'urge.game_tictactoe', descKey: 'urge.game_tictactoe_desc' },
{ id: 'snake', svg: snakeSvg, titleKey: 'urge.game_snake', descKey: 'urge.game_snake_desc' },
{ id: 'tetris', svg: tetrisSvg, titleKey: 'urge.game_tetris', descKey: 'urge.game_tetris_desc' },
];
// ── Game picker grid ──────────────────────────────────────────────────────────
export function GamePickerGrid({ onSelect }: { onSelect: (game: GameType) => void }) {
const { t } = useTranslation();
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
{GAME_META.map((game) => (
<Pressable
key={game.id}
onPress={() => onSelect(game.id)}
style={({ pressed }) => ({
width: '47%',
opacity: pressed ? 0.75 : 1,
})}
>
<View style={{
aspectRatio: 1,
borderRadius: 16,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#f9fafb',
alignItems: 'center',
justifyContent: 'center',
padding: 12,
}}>
<SvgXml xml={game.svg} width={56} height={56} />
<Text style={{ marginTop: 10, fontFamily: 'Nunito_700Bold', color: '#111827', fontSize: 14 }}>
{t(game.titleKey)}
</Text>
<Text
numberOfLines={2}
style={{
marginTop: 4,
textAlign: 'center',
fontFamily: 'Nunito_400Regular',
color: '#6b7280',
fontSize: 11,
lineHeight: 14,
minHeight: 28,
}}
>
{t(game.descKey)}
</Text>
</View>
</Pressable>
))}
</View>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function shuffle<T>(arr: T[]): T[] {
const out = [...arr];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = out[i]!;
out[i] = out[j]!;
out[j] = tmp;
}
return out;
}
// ═══════════════════════════════════════════════════════════════════════════════
// SNAKE — 1:1 Port von apps/rebreak/app/components/sos/GameSnake.vue
// ═══════════════════════════════════════════════════════════════════════════════
const SNAKE_ROWS = 20;
const SNAKE_COLS = 15;
const SNAKE_TICK_MS = 180;
type Dir = 'up' | 'down' | 'left' | 'right';
interface Pos { row: number; col: number }
const OPPOSITES: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' };
export function SnakeGame({
onComplete,
onAbandon,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
const insets = useSafeAreaInsets();
// Cell size aus Bildschirmgröße — Header(80) + Drawer-Padding(40) + DPad(220) + Spacing(40) + Home-Indicator
const cell = useMemo(() => {
const win = Dimensions.get('window');
const maxW = Math.min(win.width - 32, 400);
const maxH = win.height - 80 - 40 - 220 - 40 - Math.max(insets.bottom, 16);
const cellByW = Math.floor(maxW / SNAKE_COLS);
const cellByH = Math.floor(maxH / SNAKE_ROWS);
return Math.max(12, Math.min(cellByW, cellByH));
}, [insets.bottom]);
const boardW = SNAKE_COLS * cell;
const boardH = SNAKE_ROWS * cell;
const [snake, setSnake] = useState<Pos[]>([
{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 },
]);
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 nextDirRef = useRef<Dir>('right');
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [activeDPad, setActiveDPad] = useState<Dir>('right');
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load high score
useEffect(() => {
AsyncStorage.getItem('rebreak-snake-highscore').then((v) => {
if (v) setHighScore(parseInt(v) || 0);
});
}, []);
function setDir(d: Dir) {
if (OPPOSITES[d] !== dirRef.current) nextDirRef.current = d;
}
function onDPad(d: Dir) {
setDir(d);
setActiveDPad(d);
}
function randomFood(currentSnake: Pos[]): Pos {
const occupied = new Set(currentSnake.map((p) => p.row * SNAKE_COLS + p.col));
let pos: Pos;
do {
pos = { row: Math.floor(Math.random() * SNAKE_ROWS), col: Math.floor(Math.random() * SNAKE_COLS) };
} while (occupied.has(pos.row * SNAKE_COLS + pos.col));
return pos;
}
function endGame(finalScore: number) {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
setGameOver(true);
if (finalScore > highScore) {
setHighScore(finalScore);
AsyncStorage.setItem('rebreak-snake-highscore', String(finalScore)).catch(() => {});
}
setTimeout(() => onComplete(finalScore), 500);
}
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
// Reading snake/food from refs avoids stale closures and prevents duplicate setFood calls
// when React (StrictMode) invokes a state-updater twice.
useEffect(() => {
if (gameOver) return;
intervalRef.current = setInterval(() => {
dirRef.current = nextDirRef.current;
const prev = snakeRef.current;
const head = prev[0];
if (!head) return;
const next: Pos = { row: head.row, col: head.col };
if (dirRef.current === 'up') next.row--;
else if (dirRef.current === 'down') next.row++;
else if (dirRef.current === 'left') next.col--;
else if (dirRef.current === 'right') next.col++;
if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) {
endGame(score);
return;
}
if (prev.some((s) => s.row === next.row && s.col === next.col)) {
endGame(score);
return;
}
const currentFood = foodRef.current;
const ate = next.row === currentFood.row && next.col === currentFood.col;
const newSnake = [next, ...prev];
if (!ate) newSnake.pop();
snakeRef.current = newSnake;
setSnake(newSnake);
if (ate) {
const newFood = randomFood(newSnake);
foodRef.current = newFood;
setFood(newFood);
setScore((s) => s + 1);
}
}, SNAKE_TICK_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameOver, score, highScore]);
// Swipe gestures
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderRelease: (_, g) => {
const dx = g.dx, dy = g.dy;
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
if (Math.abs(dx) > Math.abs(dy)) onDPad(dx > 0 ? 'right' : 'left');
else onDPad(dy > 0 ? 'down' : 'up');
},
}),
[],
);
// Lyra message based on score
const lyraMessage =
score >= 8 ? 'Voll im Flow der Impuls hat keine Chance!' :
score >= 5 ? 'Sehr gut! Bleib konzentriert.' :
score >= 3 ? 'Super! Weiter so.' :
'Sammle die roten Äpfel.';
// SVG geometry
const snakePoints = snake
.map((s) => `${s.col * cell + cell / 2},${s.row * cell + cell / 2}`)
.join(' ');
const head = snake[0];
const eyePositions = head
? (() => {
const cx = head.col * cell + cell / 2;
const cy = head.row * cell + cell / 2;
const off = cell * 0.22;
switch (dirRef.current) {
case 'right': return [{ x: cx + off * 0.8, y: cy - off }, { x: cx + off * 0.8, y: cy + off }];
case 'left': return [{ x: cx - off * 0.8, y: cy - off }, { x: cx - off * 0.8, y: cy + off }];
case 'up': return [{ x: cx - off, y: cy - off * 0.8 }, { x: cx + off, y: cy - off * 0.8 }];
case 'down': return [{ x: cx - off, y: cy + off * 0.8 }, { x: cx + off, y: cy + off * 0.8 }];
}
})()
: [];
const pupilPositions = eyePositions.map((e) => {
const d = cell * 0.04;
switch (dirRef.current) {
case 'right': return { x: e.x + d, y: e.y };
case 'left': return { x: e.x - d, y: e.y };
case 'up': return { x: e.x, y: e.y - d };
case 'down': return { x: e.x, y: e.y + d };
}
});
const tongue = head
? (() => {
const cx = head.col * cell + cell / 2;
const cy = head.row * cell + cell / 2;
const len = cell * 0.45;
const base = cell * 0.4;
switch (dirRef.current) {
case 'right': return { x1: cx + base, y1: cy, x2: cx + base + len, y2: cy };
case 'left': return { x1: cx - base, y1: cy, x2: cx - base - len, y2: cy };
case 'up': return { x1: cx, y1: cy - base, x2: cx, y2: cy - base - len };
case 'down': return { x1: cx, y1: cy + base, x2: cx, y2: cy + base + len };
}
})()
: null;
return (
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
{/* Header */}
<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>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 14 }}>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Score</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#16a34a' }}>{score}</Text>
</View>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>Best</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{highScore}</Text>
</View>
<Pressable onPress={onAbandon} hitSlop={10} style={{ width: 28, height: 28, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ fontSize: 18, color: '#6b7280' }}></Text>
</Pressable>
</View>
</View>
{/* Board */}
<View style={{ alignItems: 'center' }} {...panResponder.panHandlers}>
<View style={{ width: boardW, height: boardH, borderRadius: 12, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(0,230,128,0.2)', backgroundColor: '#0d1117' }}>
<Svg width={boardW} height={boardH}>
<Defs>
<Pattern id="sg" width={cell} height={cell} patternUnits="userSpaceOnUse">
<Path d={`M ${cell} 0 L 0 0 0 ${cell}`} fill="none" stroke="#00e680" strokeWidth={0.5} opacity={0.06} />
</Pattern>
</Defs>
<Rect width="100%" height="100%" fill="url(#sg)" />
{snake.length >= 2 && (
<Polyline points={snakePoints} fill="none" stroke="#009954" strokeWidth={cell * 0.68} strokeLinecap="round" strokeLinejoin="round" />
)}
{head && <Circle cx={head.col * cell + cell / 2} cy={head.row * cell + cell / 2} r={cell * 0.42} fill="#00e680" />}
{eyePositions.map((eye, ei) => (
<Circle key={'e' + ei} cx={eye.x} cy={eye.y} r={cell * 0.09} fill="white" />
))}
{pupilPositions.map((p, pi) => (
<Circle key={'p' + pi} cx={p.x} cy={p.y} r={cell * 0.045} fill="#0d1117" />
))}
{tongue && !gameOver && (
<Line x1={tongue.x1} y1={tongue.y1} x2={tongue.x2} y2={tongue.y2} stroke="#d32f2f" strokeWidth={1.5} strokeLinecap="round" />
)}
<Circle cx={food.col * cell + cell / 2} cy={food.row * cell + cell / 2} r={cell * 0.33} fill="#d32f2f" />
</Svg>
</View>
</View>
{/* D-Pad */}
{!gameOver && (
<View style={{ alignItems: 'center', marginTop: 18, gap: 10 }}>
<DPadBtn dir="up" active={activeDPad === 'up'} onPress={() => onDPad('up')} />
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<DPadBtn dir="left" active={activeDPad === 'left'} onPress={() => onDPad('left')} />
<View style={{ width: 60, height: 60, borderRadius: 30, backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center' }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#d1d5db' }} />
</View>
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
</View>
<DPadBtn dir="down" active={activeDPad === 'down'} onPress={() => onDPad('down')} />
</View>
)}
{gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 10 }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} {score === 1 ? 'Apfel' : 'Äpfel'} gesammelt</Text>
</View>
)}
</View>
);
}
// Platform-native D-Pad button: iOS uses system-blue tinted circle (SF-symbol look),
// Android uses Material elevated surface with ripple.
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
};
const isIOS = Platform.OS === 'ios';
const tint = '#007aff';
return (
<Pressable
onPress={() => { tapHaptic(); onPress(); }}
hitSlop={12}
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
style={({ pressed }) => {
const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff';
const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5';
const bgActive = tint;
const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle);
return {
width: 60, height: 60, borderRadius: 30,
backgroundColor: bg,
alignItems: 'center', justifyContent: 'center',
...(isIOS ? {} : {
elevation: active ? 4 : 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.15,
shadowRadius: 2,
}),
transform: [{ scale: pressed && isIOS ? 0.96 : 1 }],
};
}}
>
<Ionicons
name={icons[dir]}
size={28}
color={active ? '#ffffff' : tint}
/>
</Pressable>
);
}
// Action button für Tetris (Rotate, Drop) — größer & mit Label.
// Idle: tönt sich leicht in accent-Farbe ein (kein white-on-white-Verlust auf weißem Screen).
function TetrisActionBtn({
icon, label, onPress, accent,
}: {
icon: 'sync' | 'arrow-down';
label: string;
onPress: () => void;
accent?: string;
}) {
const accentColor = accent || '#1f2937';
return (
<Pressable
onPress={() => { mediumHaptic(); onPress(); }}
hitSlop={12}
android_ripple={{ color: accentColor + '33', borderless: false }}
style={({ pressed }) => ({
width: 72, height: 72, borderRadius: 20,
// accent + '14' = ~8% Tönung im Idle-State, accent solid auf Press
backgroundColor: pressed ? accentColor : accentColor + '14',
borderWidth: 1.5,
borderColor: accentColor,
alignItems: 'center', justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: pressed ? 0.05 : 0.12,
shadowRadius: 5,
elevation: pressed ? 1 : 3,
transform: [{ scale: pressed ? 0.95 : 1 }],
})}
>
{({ pressed }) => (
<>
<Ionicons name={icon} size={26} color={pressed ? '#ffffff' : accentColor} />
<Text style={{ fontSize: 10, marginTop: 2, fontFamily: 'Nunito_700Bold', color: pressed ? '#ffffff' : accentColor }}>{label}</Text>
</>
)}
</Pressable>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// MEMORY — 1:1 Port von apps/rebreak/app/components/sos/GameMemory.vue
// ═══════════════════════════════════════════════════════════════════════════════
const MEMORY_PAIRS = 8;
const MEMORY_EMOJIS = ['🛡️', '💪', '🌟', '🧠', '🌊', '🎯', '🌱', '🔑'];
export function MemoryGame({
onComplete,
onAbandon,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
type Card = { id: number; emoji: string; matched: boolean; revealed: boolean };
const [cards, setCards] = useState<Card[]>([]);
const [flipped, setFlipped] = useState<number[]>([]);
const [moveCount, setMoveCount] = useState(0);
const [matchedCount, setMatchedCount] = useState(0);
const [blocked, setBlocked] = useState(false);
function init() {
const pairs = shuffle([...MEMORY_EMOJIS, ...MEMORY_EMOJIS]);
setCards(pairs.map((emoji, id) => ({ id, emoji, matched: false, revealed: false })));
setFlipped([]);
setMoveCount(0);
setMatchedCount(0);
setBlocked(false);
}
useEffect(() => { init(); }, []);
const lyraMessage = (() => {
const r = matchedCount / MEMORY_PAIRS;
if (r === 0) return 'Dreh die erste Karte um nimm dir Zeit.';
if (r < 0.25) return 'Gut. Merk dir die Positionen genau.';
if (r < 0.5) return 'Du machst das super! Konzentrier dich weiter.';
if (r < 0.75) return 'Mehr als die Hälfte! Du bist fast da.';
return 'Wow nur noch wenige Paare! 🔥';
})();
function flip(id: number) {
if (blocked) return;
const card = cards[id];
if (!card || card.matched || card.revealed) return;
if (flipped.length >= 2) return;
const next = cards.slice();
next[id] = { ...next[id]!, revealed: true };
setCards(next);
const nextFlipped = [...flipped, id];
setFlipped(nextFlipped);
if (nextFlipped.length === 2) {
const newMoveCount = moveCount + 1;
setMoveCount(newMoveCount);
const [a, b] = nextFlipped;
const ca = next[a]!, cb = next[b]!;
if (ca.emoji === cb.emoji) {
const matched = next.slice();
matched[a] = { ...ca, matched: true };
matched[b] = { ...cb, matched: true };
setCards(matched);
setFlipped([]);
const newMatched = matchedCount + 1;
setMatchedCount(newMatched);
if (newMatched === MEMORY_PAIRS) {
setTimeout(() => onComplete(newMoveCount), 600);
}
} else {
setBlocked(true);
setTimeout(() => {
const reverted = next.slice();
reverted[a] = { ...reverted[a]!, revealed: false };
reverted[b] = { ...reverted[b]!, revealed: false };
setCards(reverted);
setFlipped([]);
setBlocked(false);
}, 900);
}
}
}
return (
<View style={{ paddingHorizontal: 12 }}>
{/* Lyra Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'Nunito_600SemiBold' }}>Lyra</Text>
<Text style={{ fontSize: 13, color: '#111827', lineHeight: 18 }}>{lyraMessage}</Text>
</View>
<View style={{ alignItems: 'flex-end' }}>
<Text style={{ fontSize: 10, color: '#9ca3af' }}>Züge</Text>
<Text style={{ fontSize: 18, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{moveCount}</Text>
</View>
<Pressable onPress={onAbandon} hitSlop={10}>
<Text style={{ fontSize: 18, color: '#6b7280' }}></Text>
</Pressable>
</View>
{/* Progress */}
<View style={{ height: 6, backgroundColor: '#e5e7eb', borderRadius: 999, overflow: 'hidden', marginTop: 12 }}>
<View style={{ height: '100%', width: `${(matchedCount / MEMORY_PAIRS) * 100}%`, backgroundColor: '#16a34a', borderRadius: 999 }} />
</View>
{/* Card Grid 4x4 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: 12, gap: 8 }}>
{cards.map((card) => {
const showFace = card.revealed || card.matched;
return (
<Pressable
key={card.id}
onPress={() => flip(card.id)}
style={{
width: '22.7%', aspectRatio: 1, borderRadius: 12, borderWidth: 1.5,
borderColor: card.matched ? '#86efac' : showFace ? '#93c5fd' : '#e5e7eb',
backgroundColor: card.matched ? '#f0fdf4' : showFace ? '#eff6ff' : '#f9fafb',
alignItems: 'center', justifyContent: 'center',
opacity: blocked && !showFace ? 0.6 : 1,
transform: [{ scale: card.matched ? 0.95 : 1 }],
}}
>
<Text style={{ fontSize: 28 }}>{showFace ? card.emoji : '🛡️'}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// TIC-TAC-TOE — 1:1 Port von apps/rebreak/app/components/sos/GameTicTacToe.vue
// ═══════════════════════════════════════════════════════════════════════════════
type TTCell = 'X' | 'O' | null;
type TTResult = 'player' | 'lyra' | 'draw' | null;
const TT_WIN_LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
function ttCheckWinner(b: TTCell[]): { winner: 'X' | 'O' | null; line: number[] } {
for (const line of TT_WIN_LINES) {
const [a, c, d] = line as [number, number, number];
if (b[a] && b[a] === b[c] && b[a] === b[d]) return { winner: b[a]!, line };
}
return { winner: null, line: [] };
}
function ttEmpty(b: TTCell[]) { return b.map((c, i) => c === null ? i : -1).filter((i) => i !== -1); }
function ttWouldWin(b: TTCell[], idx: number, mark: 'X' | 'O') {
const next = [...b]; next[idx] = mark;
return ttCheckWinner(next).winner === mark;
}
function ttLyraAI(b: TTCell[]): number {
const empty = ttEmpty(b);
for (const i of empty) if (ttWouldWin(b, i, 'O')) return i; // win
for (const i of empty) if (ttWouldWin(b, i, 'X')) return i; // block
if (b[4] === null) return 4; // center
const corners = [0, 2, 6, 8].filter((i) => b[i] === null);
if (corners.length) return corners[Math.floor(Math.random() * corners.length)]!;
return empty[Math.floor(Math.random() * empty.length)]!;
}
export function TicTacToeGame({
onComplete,
onAbandon,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
const [board, setBoard] = useState<TTCell[]>(Array(9).fill(null));
const [gameOver, setGameOver] = useState(false);
const [result, setResult] = useState<TTResult>(null);
const [winLine, setWinLine] = useState<number[]>([]);
const [lyraThinking, setLyraThinking] = useState(false);
const [playerScore, setPlayerScore] = useState(0);
const [lyraScore, setLyraScore] = useState(0);
const [round, setRound] = useState(1);
const resultText =
result === 'player' ? '🎉 Du gewinnst diese Runde!' :
result === 'lyra' ? '🤖 Lyra gewinnt diese Runde' :
'🤝 Unentschieden';
const resultColor = result === 'player' ? '#16a34a' : result === 'lyra' ? '#f43f5e' : '#eab308';
const lyraMessage = (() => {
if (lyraThinking) return 'Hmm, lass mich kurz nachdenken…';
if (result === 'player') return 'Gut gespielt! Du hast diese Runde gewonnen. 👏';
if (result === 'lyra') return 'Diesmal war ich schneller. Versuch es nochmal!';
if (result === 'draw') return 'Unentschieden! Gut gekämpft.';
const empty = board.filter((c) => c === null).length;
if (empty === 9) return 'Du fängst an ich bin Lyra, du spielst X.';
if (empty <= 3) return 'Das wird spannend noch wenige Felder frei!';
return 'Dein Zug. Ich beobachte genau.';
})();
function applyResult(mark: 'X' | 'O', line: number[]) {
setWinLine(line);
setGameOver(true);
if (mark === 'X') { setResult('player'); setPlayerScore((s) => s + 1); }
else { setResult('lyra'); setLyraScore((s) => s + 1); }
}
function playerMove(i: number) {
if (board[i] || gameOver || lyraThinking) return;
const next = [...board]; next[i] = 'X';
setBoard(next);
const r = ttCheckWinner(next);
if (r.winner) { applyResult('X', r.line); return; }
if (ttEmpty(next).length === 0) { setGameOver(true); setResult('draw'); return; }
setLyraThinking(true);
setTimeout(() => {
const idx = ttLyraAI(next);
const after = [...next]; after[idx] = 'O';
setBoard(after);
const r2 = ttCheckWinner(after);
if (r2.winner) applyResult('O', r2.line);
else if (ttEmpty(after).length === 0) { setGameOver(true); setResult('draw'); }
setLyraThinking(false);
}, 600);
}
function newRound() {
setBoard(Array(9).fill(null));
setGameOver(false);
setResult(null);
setWinLine([]);
setRound((r) => r + 1);
}
return (
<View style={{ paddingHorizontal: 16, gap: 12 }}>
{/* Lyra Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, color: '#9ca3af', fontFamily: 'Nunito_600SemiBold' }}>Lyra</Text>
<Text style={{ fontSize: 13, color: '#111827', lineHeight: 18 }}>{lyraMessage}</Text>
</View>
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{playerScore}</Text>
<Text style={{ fontSize: 10, color: '#9ca3af' }}>Du</Text>
</View>
<Text style={{ color: '#9ca3af' }}>:</Text>
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color: '#111827' }}>{lyraScore}</Text>
<Text style={{ fontSize: 10, color: '#9ca3af' }}>Lyra</Text>
</View>
</View>
</View>
{/* Board */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', maxWidth: 320, alignSelf: 'center', width: '100%', gap: 8 }}>
{board.map((cell, i) => {
const isWin = winLine.includes(i);
return (
<Pressable
key={i}
onPress={() => playerMove(i)}
disabled={!!cell || gameOver || lyraThinking}
style={{
width: '31%', aspectRatio: 1, borderRadius: 16, borderWidth: 1.5,
borderColor: isWin ? '#facc15' : cell === 'X' ? '#3b82f6' : cell === 'O' ? '#f43f5e' : '#e5e7eb',
backgroundColor: isWin ? '#fef9c3' : cell === 'X' ? '#eff6ff' : cell === 'O' ? '#fee2e2' : '#f9fafb',
alignItems: 'center', justifyContent: 'center',
}}
>
<Text style={{ fontSize: 36, fontFamily: 'Nunito_800ExtraBold', color: cell === 'X' ? '#3b82f6' : cell === 'O' ? '#f43f5e' : '#111827' }}>{cell ?? ''}</Text>
</Pressable>
);
})}
</View>
{/* Status */}
<View style={{ backgroundColor: '#f9fafb', borderWidth: 1, borderColor: '#e5e7eb', borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, alignItems: 'center' }}>
{lyraThinking ? (
<Text style={{ fontSize: 13, color: '#6b7280' }}>Lyra denkt nach</Text>
) : gameOver ? (
<>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: resultColor }}>{resultText}</Text>
<Text style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>Runde {round}</Text>
</>
) : (
<Text style={{ fontSize: 13, color: '#6b7280' }}>Dein Zug du spielst <Text style={{ fontFamily: 'Nunito_800ExtraBold', color: '#3b82f6' }}>X</Text></Text>
)}
</View>
{/* Actions */}
{gameOver ? (
<View style={{ flexDirection: 'row', gap: 10 }}>
<Pressable onPress={newRound} style={{ flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: '#f3f4f6', alignItems: 'center' }}>
<Text style={{ fontFamily: 'Nunito_700Bold', color: '#374151' }}>Nochmal</Text>
</Pressable>
<Pressable onPress={() => onComplete(playerScore)} style={{ flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: '#16a34a', alignItems: 'center' }}>
<Text style={{ fontFamily: 'Nunito_700Bold', color: '#fff' }}>Fertig </Text>
</Pressable>
</View>
) : (
<Pressable onPress={onAbandon} style={{ paddingVertical: 10, alignItems: 'center' }}>
<Text style={{ color: '#6b7280', fontFamily: 'Nunito_600SemiBold' }}>Abbrechen</Text>
</Pressable>
)}
</View>
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// TETRIS — 1:1 Port von apps/rebreak/app/components/sos/GameTetris.vue
// ═══════════════════════════════════════════════════════════════════════════════
const TETRIS_COLS = 10;
const TETRIS_ROWS = 20;
const TETRIS_SPEED_BASES = [1400, 1000, 700, 450, 250] as const;
const TETRIS_PIECES = [
{ shape: [[1, 1, 1, 1]], color: '#22d3ee' }, // I
{ shape: [[1, 1], [1, 1]], color: '#fbbf24' }, // O
{ shape: [[0, 1, 0], [1, 1, 1]], color: '#a78bfa' }, // T
{ shape: [[0, 1, 1], [1, 1, 0]], color: '#34d399' }, // S
{ shape: [[1, 1, 0], [0, 1, 1]], color: '#f87171' }, // Z
{ shape: [[1, 0, 0], [1, 1, 1]], color: '#60a5fa' }, // J
{ shape: [[0, 0, 1], [1, 1, 1]], color: '#fb923c' }, // L
];
type TetrisPiece = { shape: number[][]; color: string; x: number; y: number };
function tetrisEmptyBoard(): string[][] {
return Array.from({ length: TETRIS_ROWS }, () => Array(TETRIS_COLS).fill(''));
}
function tetrisRandomPiece() {
const p = TETRIS_PIECES[Math.floor(Math.random() * TETRIS_PIECES.length)]!;
return { shape: p.shape.map((r) => [...r]), color: p.color };
}
function tetrisRotate(shape: number[][]) {
return shape[0]!.map((_, i) => shape.map((row) => row[i]!).reverse());
}
export function TetrisGame({
onComplete,
onAbandon,
}: {
onComplete: (score: number) => void;
onAbandon: () => void;
}) {
const insets = useSafeAreaInsets();
// CELL aus Bildschirmgröße — Header(80) + Padding(40) + Speed-Stepper(50) + Controls(110) + Spacing(20) + Home-Indicator
const CELL = useMemo(() => {
const win = Dimensions.get('window');
const maxW = Math.min(win.width - 32, 400);
const maxH = win.height - 80 - 40 - 50 - 110 - 20 - Math.max(insets.bottom, 16);
const cellByW = Math.floor(maxW / TETRIS_COLS);
const cellByH = Math.floor(maxH / TETRIS_ROWS);
return Math.max(12, Math.min(cellByW, cellByH));
}, [insets.bottom]);
const [board, setBoard] = useState<string[][]>(tetrisEmptyBoard());
const [current, setCurrent] = useState<TetrisPiece | null>(null);
const nextPieceRef = useRef(tetrisRandomPiece());
const [score, setScore] = useState(0);
const [level, setLevel] = useState(1);
const [lines, setLines] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [highScore, setHighScore] = useState(0);
const [speedLevel, setSpeedLevel] = useState(3);
const tickTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const boardRef = useRef(board);
const currentRef = useRef(current);
useEffect(() => { boardRef.current = board; }, [board]);
useEffect(() => { currentRef.current = current; }, [current]);
// Load high score
useEffect(() => {
AsyncStorage.getItem('rebreak-tetris-highscore').then((v) => {
if (v) setHighScore(parseInt(v) || 0);
});
}, []);
function isValid(piece: TetrisPiece, px: number, py: number, shape = piece.shape): boolean {
const b = boardRef.current;
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r]!.length; c++) {
if (!shape[r]![c]) continue;
const nx = px + c, ny = py + r;
if (nx < 0 || nx >= TETRIS_COLS || ny >= TETRIS_ROWS) return false;
if (ny >= 0 && b[ny]![nx]) return false;
}
}
return true;
}
const spawnPiece = useCallback(() => {
const p = nextPieceRef.current;
nextPieceRef.current = tetrisRandomPiece();
const x = Math.floor((TETRIS_COLS - p.shape[0]!.length) / 2);
const newPiece: TetrisPiece = { ...p, x, y: 0 };
if (!isValid(newPiece, newPiece.x, newPiece.y)) {
setGameOver(true);
stopTick();
const finalScore = score;
if (finalScore > highScore) {
setHighScore(finalScore);
AsyncStorage.setItem('rebreak-tetris-highscore', String(finalScore)).catch(() => {});
}
setTimeout(() => onComplete(finalScore), 500);
return;
}
setCurrent(newPiece);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [score, highScore, onComplete]);
function lockPiece() {
const piece = currentRef.current;
if (!piece) return;
const b = boardRef.current.map((r) => [...r]);
for (let r = 0; r < piece.shape.length; r++) {
for (let c = 0; c < piece.shape[r]!.length; c++) {
if (!piece.shape[r]![c]) continue;
const ny = piece.y + r, nx = piece.x + c;
if (ny >= 0) b[ny]![nx] = piece.color;
}
}
const cleared = b.filter((row) => row.every((c) => c !== ''));
const kept = b.filter((row) => row.some((c) => c === ''));
const newLines = cleared.length;
if (newLines > 0) {
setLines((l) => l + newLines);
const pts = [0, 100, 300, 500, 800][newLines] ?? 800;
setScore((s) => s + pts * level);
setLevel((lv) => Math.floor((lines + newLines) / 10) + 1);
resetTick();
}
const nextBoard = [
...Array.from({ length: newLines }, () => Array(TETRIS_COLS).fill('')),
...kept,
];
setBoard(nextBoard);
boardRef.current = nextBoard;
setCurrent(null);
setTimeout(() => spawnPiece(), 0);
}
function moveLeft() {
const p = currentRef.current; if (!p || gameOver) return;
if (isValid(p, p.x - 1, p.y)) setCurrent({ ...p, x: p.x - 1 });
}
function moveRight() {
const p = currentRef.current; if (!p || gameOver) return;
if (isValid(p, p.x + 1, p.y)) setCurrent({ ...p, x: p.x + 1 });
}
function rotatePiece() {
const p = currentRef.current; if (!p || gameOver) return;
const rotated = tetrisRotate(p.shape);
for (const kick of [0, -1, 1, -2, 2]) {
if (isValid(p, p.x + kick, p.y, rotated)) {
setCurrent({ ...p, shape: rotated, x: p.x + kick });
return;
}
}
}
function softDrop() {
const p = currentRef.current; if (!p || gameOver) return;
if (isValid(p, p.x, p.y + 1)) {
setCurrent({ ...p, y: p.y + 1 });
setScore((s) => s + 1);
} else {
lockPiece();
}
}
function tickInterval() {
const base = TETRIS_SPEED_BASES[speedLevel - 1]!;
return Math.max(80, base - (level - 1) * 40);
}
function startTick() {
tickTimerRef.current = setInterval(() => {
const p = currentRef.current;
if (!p || gameOver) return;
if (isValid(p, p.x, p.y + 1)) setCurrent({ ...p, y: p.y + 1 });
else lockPiece();
}, tickInterval());
}
function stopTick() {
if (tickTimerRef.current) { clearInterval(tickTimerRef.current); tickTimerRef.current = null; }
}
function resetTick() { stopTick(); startTick(); }
// Init
useEffect(() => {
spawnPiece();
startTick();
return () => stopTick();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-tick when speed/level changes
useEffect(() => { if (!gameOver) resetTick(); /* eslint-disable-next-line */ }, [speedLevel, level]);
// Display board (board + current piece)
const displayBoard = useMemo(() => {
const b = board.map((r) => [...r]);
if (current) {
for (let r = 0; r < current.shape.length; r++) {
for (let c = 0; c < current.shape[r]!.length; c++) {
if (!current.shape[r]![c]) continue;
const ny = current.y + r, nx = current.x + c;
if (ny >= 0 && ny < TETRIS_ROWS && nx >= 0 && nx < TETRIS_COLS) b[ny]![nx] = current.color;
}
}
}
return b;
}, [board, current]);
// Ghost piece
const ghostCells = useMemo(() => {
if (!current) return [];
let gy = current.y;
while (isValid(current, current.x, gy + 1)) gy++;
if (gy === current.y) return [];
const cells: [number, number][] = [];
for (let r = 0; r < current.shape.length; r++)
for (let c = 0; c < current.shape[r]!.length; c++)
if (current.shape[r]![c]) cells.push([current.x + c, gy + r]);
return cells;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current, board]);
const lyraMessage =
lines >= 10 ? 'Unglaublich! Du bist voll im Flow! 🔥' :
lines >= 3 ? 'Super Linie! Weiter so.' :
'Stapel die Blöcke du schaffst das!';
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
return (
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
{/* Header */}
<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>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Stat label="Score" value={score} color="#111827" />
{highScore > 0 && <Stat label="Best" value={highScore} color="#f59e0b" />}
<Stat label="Lvl" value={level} color="#3b82f6" />
<Stat label="Lines" value={lines} color="#111827" />
<Pressable onPress={onAbandon} hitSlop={10}><Text style={{ fontSize: 18, color: '#6b7280' }}></Text></Pressable>
</View>
</View>
{/* Board */}
<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' }}>
{displayBoard.map((row, y) => (
<View key={y} style={{ flexDirection: 'row', height: CELL }}>
{row.map((color, x) => (
<View key={x} style={{
width: CELL, height: CELL, padding: 0.5,
}}>
<View style={{
flex: 1, borderRadius: 2,
backgroundColor: color || 'transparent',
}} />
</View>
))}
</View>
))}
{/* Ghost piece overlay */}
{ghostCells.map(([gx, gy], i) => (
<View key={'g' + i} style={{
position: 'absolute', left: gx * CELL, top: gy * CELL, width: CELL - 1, height: CELL - 1,
borderRadius: 2, backgroundColor: current?.color, opacity: 0.2,
}} />
))}
</View>
</View>
{/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */}
<View style={{ marginTop: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="flash" size={14} color={speedColors[speedLevel - 1]} />
<Text style={{ fontSize: 12, color: '#6b7280', fontFamily: 'Nunito_600SemiBold' }}>Tempo</Text>
</View>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_800ExtraBold', color: speedColors[speedLevel - 1] }}>
Speed {speedLevel}
</Text>
</View>
<Slider
style={{ width: '100%', height: Platform.OS === 'ios' ? 32 : 40 }}
minimumValue={1}
maximumValue={5}
step={1}
value={speedLevel}
onValueChange={(v) => {
const nv = Math.round(v);
if (nv !== speedLevel) {
tapHaptic();
setSpeedLevel(nv);
}
}}
minimumTrackTintColor={speedColors[speedLevel - 1]}
maximumTrackTintColor="#e5e7eb"
thumbTintColor={Platform.OS === 'android' ? speedColors[speedLevel - 1] : undefined}
/>
</View>
{/* Controls — Move Pad (links) + Action Pad (rechts) */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 18 }}>
{/* Move Pad */}
<View style={{ flexDirection: 'row', gap: 14 }}>
<DPadBtn dir="left" active={false} onPress={moveLeft} />
<DPadBtn dir="right" active={false} onPress={moveRight} />
</View>
{/* Action Pad */}
<View style={{ flexDirection: 'row', gap: 14 }}>
<TetrisActionBtn icon="sync" label="Drehen" accent="#7c3aed" onPress={rotatePiece} />
<TetrisActionBtn icon="arrow-down" label="Drop" accent="#0ea5e9" onPress={softDrop} />
</View>
</View>
{gameOver && (
<View style={{ marginTop: 14, alignItems: 'center', gap: 6 }}>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>Game Over</Text>
<Text style={{ fontSize: 14, color: '#6b7280' }}>{score} Punkte · {lines} Linien</Text>
</View>
)}
</View>
);
}
function Stat({ label, value, color }: { label: string; value: number; color: string }) {
return (
<View style={{ alignItems: 'center' }}>
<Text style={{ fontSize: 9, color: '#9ca3af', textTransform: 'uppercase', letterSpacing: 0.5 }}>{label}</Text>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_800ExtraBold', color }}>{value}</Text>
</View>
);
}