chahinebrini 964dc2b6e0 fix(native/games): game-over modal — maxHeight 85%, KeyboardAvoidingView, Button comp, regenerate
Four issues from the screenshot review plus one new affordance:

1. Modal overflowing on small devices — capped at maxHeight: '85%'. Header
   (handle bar + Lyra avatar + title + subtitle) stays fixed above a
   ScrollView body; action buttons stay fixed below with a border separator.
   Stat cards, star rating, and TextInput now live inside the scrollable body.

2. Keyboard pushed the TextInput out of sight — replaced the bespoke
   Keyboard.addListener + Animated.multiply lift hack (Easing, keyboardLiftY,
   the whole apparatus) with a plain KeyboardAvoidingView wrapper
   (behavior="padding" iOS / "height" Android). ScrollView already had
   keyboardShouldPersistTaps="handled" so taps on Posten/Abbrechen still
   work while the keyboard is up.

3. All four action buttons (Nochmal, Beenden, Abbrechen, Posten) plus the
   inner Save-Rating CTA now route through components/Button.tsx — picks
   up the slimmer paddingVertical:12 default from the central component.
   Posten gets the paper-plane icon. Nochmal + Posten = primary, Beenden +
   Abbrechen = secondary.

4. New "Neuer Vorschlag" regenerate button (ghost variant, sm size,
   refresh-outline icon) sits between the TextInput and the Abbrechen/
   Posten row. Reuses POST /api/games/share-text — no new endpoint. Tracks
   the last Lyra-generated text in a ref so we can detect user edits; if
   the user has modified the suggestion, taps go through an Alert.alert
   confirm before overwrite. Spinner during the regen call, Posten /
   Abbrechen stay active. i18n keys gameOver.regen_* across DE/EN/FR.
2026-05-16 00:44:44 +02:00

476 lines
16 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Animated,
Easing,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Haptics from 'expo-haptics';
import { Ionicons } from '@expo/vector-icons';
import { RiveAvatar } from '../RiveAvatar';
import { StarRating } from './StarRating';
import { Button } from '../Button';
import { useColors } from '../../lib/theme';
import { apiFetch } from '../../lib/api';
export type GameOverScreenProps = {
score: number;
bestScore: number;
gameName: string;
scoreLabel?: string;
goodScore?: number;
onRetry: () => void;
onExit: () => void;
isNewBest?: boolean;
};
function lyraMsg(
gameName: string,
score: number,
goodScore: number,
isNewBest: boolean,
t: (k: string) => string
): { title: string; body: string } {
if (isNewBest) return { title: t('gameOver.lyra_title_record'), body: t('gameOver.lyra_body_record') };
if (score >= goodScore) return { title: t('gameOver.lyra_title_good'), body: t('gameOver.lyra_body_good') };
if (score > 0) return { title: t('gameOver.lyra_title_ok'), body: t('gameOver.lyra_body_ok') };
return { title: t('gameOver.lyra_title_low'), body: t('gameOver.lyra_body_low') };
}
export function GameOverScreen({
score,
bestScore,
gameName,
scoreLabel,
goodScore = 5,
onRetry,
onExit,
isNewBest = false,
}: GameOverScreenProps) {
const { t } = useTranslation();
const colors = useColors();
const insets = useSafeAreaInsets();
const slideAnim = useRef(new Animated.Value(500)).current;
const [rating, setRating] = useState(0);
const [feedback, setFeedback] = useState('');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [shareSectionOpen, setShareSectionOpen] = useState(false);
const [shareText, setShareText] = useState('');
const lyraShareTextRef = useRef('');
const [shareTextLoading, setShareTextLoading] = useState(false);
const [regenLoading, setRegenLoading] = useState(false);
const [sharing, setSharing] = useState(false);
const [posted, setPosted] = useState(false);
const [postError, setPostError] = useState(false);
const emotion = isNewBest || score >= goodScore ? 'happy' : 'empathy';
const msg = lyraMsg(gameName, score, goodScore, isNewBest, t);
const displayScore = score;
const displayBest = Math.max(score, bestScore);
useEffect(() => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
Animated.spring(slideAnim, {
toValue: 0,
useNativeDriver: true,
damping: 22,
stiffness: 200,
mass: 0.8,
}).start();
}, []);
function handleExit() {
Animated.timing(slideAnim, {
toValue: 500,
duration: 220,
useNativeDriver: true,
easing: Easing.in(Easing.cubic),
}).start(() => onExit());
}
async function submitRating() {
setSaving(true);
try {
await apiFetch('/api/games/rating', {
method: 'POST',
body: {
gameName: gameName.toLowerCase(),
stars: rating,
feedback: feedback.trim() || null,
score,
},
});
setSaved(true);
} catch {
// endpoint not yet live — silent
} finally {
setSaving(false);
}
}
async function fetchShareText() {
const data = await apiFetch<{ text: string }>('/api/games/share-text', {
method: 'POST',
body: {
gameName: gameName.toLowerCase(),
score,
scoreLabel,
bestScore,
isNewRecord: score > bestScore,
mode: 'game',
},
});
return data.text || `${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`;
}
async function openShareSection() {
setShareTextLoading(true);
setShareSectionOpen(true);
try {
const text = await fetchShareText();
lyraShareTextRef.current = text;
setShareText(text);
} catch {
const fallback = `${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`;
lyraShareTextRef.current = fallback;
setShareText(fallback);
} finally {
setShareTextLoading(false);
}
}
function regenerateShareText() {
if (regenLoading || shareTextLoading) return;
const doRegen = async () => {
setRegenLoading(true);
try {
const text = await fetchShareText();
lyraShareTextRef.current = text;
setShareText(text);
} catch {
// keep existing text on error
} finally {
setRegenLoading(false);
}
};
const userModified = shareText.trim() !== lyraShareTextRef.current.trim();
if (userModified) {
Alert.alert(
t('gameOver.regen_confirm_title'),
t('gameOver.regen_confirm_body'),
[
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('gameOver.regen_confirm_ok'), onPress: doRegen },
]
);
} else {
doRegen();
}
}
async function submitCommunityPost() {
if (!shareText.trim()) return;
setSharing(true);
setPostError(false);
try {
const scoreLine = `${scoreLabel ?? 'Score'}: ${score}`;
await apiFetch('/api/community/post', {
method: 'POST',
body: {
category: 'game_share',
content: `${gameName}\n${scoreLine}\n${shareText.trim()}`,
},
});
setPosted(true);
setShareSectionOpen(false);
setTimeout(() => handleExit(), 1500);
} catch (err) {
console.error('[gameover/post] failed:', err);
setPostError(true);
} finally {
setSharing(false);
}
}
const pillBg = colors.surfaceElevated;
const pillText = colors.text;
const pillMuted = colors.textMuted;
return (
<Modal visible transparent animationType="none" onRequestClose={handleExit}>
<KeyboardAvoidingView
style={{ flex: 1, justifyContent: 'flex-end' }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<TouchableOpacity onPress={handleExit} activeOpacity={1} style={{ flex: 1 }} />
<Animated.View
style={{
transform: [{ translateY: slideAnim }],
backgroundColor: colors.surface,
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingTop: 12,
maxHeight: '85%',
}}
>
{/* Grab-handle */}
<View
style={{
alignSelf: 'center',
width: 36,
height: 5,
borderRadius: 3,
backgroundColor: colors.textMuted,
opacity: 0.3,
marginBottom: 16,
}}
/>
{/* Scrollable body */}
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ gap: 16, paddingHorizontal: 20, paddingBottom: 8 }}
>
{/* Lyra avatar + message */}
<View style={{ alignItems: 'center', gap: 8 }}>
<RiveAvatar emotion={emotion} size="md" />
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
Lyra
</Text>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 18, color: colors.text, textAlign: 'center' }}>
{msg.title}
</Text>
<Text style={{ fontFamily: 'Nunito_400Regular', fontSize: 13, color: colors.textMuted, textAlign: 'center', lineHeight: 18, paddingHorizontal: 4 }}>
{msg.body}
</Text>
</View>
{/* Score pills */}
<View style={{ flexDirection: 'row', justifyContent: 'center', gap: 10 }}>
<View style={{ flex: 1, backgroundColor: pillBg, borderRadius: 14, paddingVertical: 12, paddingHorizontal: 8, alignItems: 'center', gap: 2 }}>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: pillText }}>
{displayScore}
</Text>
<Text style={{ fontSize: 10, color: pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
{scoreLabel ?? t('gameOver.score')}
</Text>
</View>
<View
style={{
flex: 1,
backgroundColor: isNewBest ? '#e7f0ff' : pillBg,
borderRadius: 12,
borderWidth: isNewBest ? 1.5 : 0,
borderColor: isNewBest ? '#007AFF' : 'transparent',
paddingVertical: 12,
paddingHorizontal: 8,
alignItems: 'center',
gap: 2,
}}
>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 20, color: isNewBest ? '#0051d4' : pillMuted }}>
{displayBest}
</Text>
<Text style={{ fontSize: 10, color: isNewBest ? '#0051d4' : pillMuted, textTransform: 'uppercase', letterSpacing: 1, fontFamily: 'Nunito_600SemiBold' }}>
{isNewBest ? t('gameOver.newBest') : t('gameOver.best')}
</Text>
</View>
</View>
{/* Star rating */}
<View style={{ alignItems: 'center', gap: 6 }}>
<StarRating
value={rating}
size="lg"
interactive={!saved}
filledColor="#007AFF"
onChange={(v) => { if (!saved) setRating(v); }}
/>
{saved ? (
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
{t('gameOver.rating_saved')}
</Text>
) : null}
</View>
{/* Feedback textarea + save */}
{rating > 0 && !saved ? (
<View style={{ gap: 8 }}>
<TextInput
value={feedback}
onChangeText={setFeedback}
placeholder={t('gameOver.feedback_placeholder')}
placeholderTextColor={colors.textMuted}
multiline
numberOfLines={2}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 12,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.text,
minHeight: 56,
textAlignVertical: 'top',
}}
/>
<Button
title={t('gameOver.save_rating')}
onPress={submitRating}
loading={saving}
variant="primary"
size="md"
/>
</View>
) : null}
{/* Share section */}
{posted ? (
<View style={{ alignItems: 'center', paddingVertical: 4, flexDirection: 'row', justifyContent: 'center', gap: 6 }}>
<Ionicons name="checkmark-circle" size={15} color={colors.success} />
<Text style={{ fontSize: 13, color: colors.success, fontFamily: 'Nunito_600SemiBold' }}>
{t('gameOver.posted')}
</Text>
</View>
) : !shareSectionOpen ? (
<TouchableOpacity
onPress={openShareSection}
activeOpacity={0.6}
style={{ alignItems: 'center', paddingVertical: 4 }}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="people-outline" size={15} color={colors.textMuted} />
<Text style={{ fontSize: 13, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{t('gameOver.share_result')}
</Text>
</View>
</TouchableOpacity>
) : (
<View style={{ gap: 10 }}>
{shareTextLoading ? (
<View style={{ alignItems: 'center', paddingVertical: 12 }}>
<ActivityIndicator size="small" color={colors.textMuted} />
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular', marginTop: 6 }}>
{t('gameOver.share_loading')}
</Text>
</View>
) : (
<TextInput
value={shareText}
onChangeText={setShareText}
multiline
numberOfLines={4}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 14,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.text,
minHeight: 100,
textAlignVertical: 'top',
}}
/>
)}
{/* Regenerate suggestion button */}
{!shareTextLoading ? (
<View style={{ alignItems: 'center' }}>
<Button
title={t('gameOver.regen_suggestion')}
onPress={regenerateShareText}
disabled={regenLoading || sharing}
loading={regenLoading}
variant="ghost"
size="sm"
icon="refresh-outline"
/>
</View>
) : null}
{postError ? (
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_600SemiBold', textAlign: 'center' }}>
{t('gameOver.post_error')}
</Text>
) : null}
<View style={{ flexDirection: 'row', gap: 12 }}>
<Button
title={t('common.cancel')}
onPress={() => { setShareSectionOpen(false); setShareText(''); setPostError(false); }}
variant="secondary"
size="md"
style={{ flex: 1 }}
/>
<Button
title={t('gameOver.post_to_community')}
onPress={submitCommunityPost}
disabled={!shareText.trim() || sharing || shareTextLoading}
loading={sharing}
variant="primary"
size="md"
icon="paper-plane-outline"
style={{ flex: 1 }}
/>
</View>
</View>
)}
</ScrollView>
{/* Fixed footer — primary action row */}
<View
style={{
flexDirection: 'row',
gap: 12,
paddingHorizontal: 20,
paddingTop: 12,
paddingBottom: insets.bottom + 16,
borderTopWidth: 1,
borderTopColor: colors.border,
}}
>
<Button
title={t('gameOver.retry')}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
onRetry();
}}
variant="primary"
size="md"
style={{ flex: 1 }}
/>
<Button
title={t('gameOver.exit')}
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
handleExit();
}}
variant="secondary"
size="md"
style={{ flex: 1 }}
/>
</View>
</Animated.View>
</KeyboardAvoidingView>
</Modal>
);
}