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.
This commit is contained in:
chahinebrini 2026-05-16 00:44:44 +02:00
parent d28d1f145d
commit 964dc2b6e0
4 changed files with 152 additions and 170 deletions

View File

@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
Animated,
Easing,
Keyboard,
KeyboardAvoidingView,
Modal,
Platform,
ScrollView,
@ -18,6 +19,7 @@ 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';
@ -59,36 +61,7 @@ export function GameOverScreen({
const colors = useColors();
const insets = useSafeAreaInsets();
// Slide-In Spring für den Sheet-Auftritt (eigene Bouncy-Animation behalten)
const slideAnim = useRef(new Animated.Value(500)).current;
// Keyboard-Lift via plain RN Keyboard.addListener (funktioniert in Modals,
// anders als react-native-keyboard-controller's useKeyboardAnimation).
const keyboardLift = useRef(new Animated.Value(0)).current;
useEffect(() => {
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const showSub = Keyboard.addListener(showEvent, (e) => {
Animated.timing(keyboardLift, {
toValue: e.endCoordinates.height,
duration: Platform.OS === 'ios' ? (e.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
});
const hideSub = Keyboard.addListener(hideEvent, (e) => {
Animated.timing(keyboardLift, {
toValue: 0,
duration: Platform.OS === 'ios' ? (e?.duration ?? 250) : 220,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
});
return () => {
showSub.remove();
hideSub.remove();
};
}, [keyboardLift]);
const [rating, setRating] = useState(0);
const [feedback, setFeedback] = useState('');
@ -97,7 +70,9 @@ export function GameOverScreen({
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);
@ -118,14 +93,12 @@ export function GameOverScreen({
}).start();
}, []);
// Negativer Lift — translateY -keyboardHeight schiebt Sheet nach oben.
const keyboardLiftY = Animated.multiply(keyboardLift, -1);
function handleExit() {
Animated.timing(slideAnim, {
toValue: 500,
duration: 220,
useNativeDriver: true,
easing: Easing.in(Easing.cubic),
}).start(() => onExit());
}
@ -149,10 +122,7 @@ export function GameOverScreen({
}
}
async function openShareSection() {
setShareTextLoading(true);
setShareSectionOpen(true);
try {
async function fetchShareText() {
const data = await apiFetch<{ text: string }>('/api/games/share-text', {
method: 'POST',
body: {
@ -164,14 +134,57 @@ export function GameOverScreen({
mode: 'game',
},
});
setShareText(data.text || `${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`);
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 {
setShareText(`${gameName}: ${score} ${scoreLabel ?? 'Punkte'}\n${t('gameOver.share_challenge')}`);
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);
@ -202,20 +215,19 @@ export function GameOverScreen({
return (
<Modal visible transparent animationType="none" onRequestClose={handleExit}>
<View style={{ flex: 1, justifyContent: 'flex-end' }}>
<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 },
{ translateY: keyboardLiftY },
],
transform: [{ translateY: slideAnim }],
backgroundColor: colors.surface,
borderTopLeftRadius: 28,
borderTopRightRadius: 28,
paddingTop: 12,
paddingHorizontal: 20,
paddingBottom: insets.bottom + 24,
maxHeight: '85%',
}}
>
{/* Grab-handle */}
@ -231,10 +243,11 @@ export function GameOverScreen({
}}
/>
{/* Scrollable body */}
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ gap: 16, paddingBottom: 8 }}
contentContainerStyle={{ gap: 16, paddingHorizontal: 20, paddingBottom: 8 }}
>
{/* Lyra avatar + message */}
<View style={{ alignItems: 'center', gap: 8 }}>
@ -319,77 +332,16 @@ export function GameOverScreen({
textAlignVertical: 'top',
}}
/>
<TouchableOpacity
<Button
title={t('gameOver.save_rating')}
onPress={submitRating}
disabled={saving}
activeOpacity={0.7}
style={{
backgroundColor: '#007AFF',
borderRadius: 12,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
opacity: saving ? 0.65 : 1,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
{saving ? t('common.loading') : t('gameOver.save_rating')}
</Text>
</TouchableOpacity>
loading={saving}
variant="primary"
size="md"
/>
</View>
) : null}
{/* Primary action row */}
<View style={{ flexDirection: 'row', gap: 12 }}>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
onRetry();
}}
activeOpacity={0.85}
style={{
flex: 1,
backgroundColor: '#007AFF',
borderRadius: 12,
minHeight: 40,
paddingVertical: 10,
paddingHorizontal: 16,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
{t('gameOver.retry')}
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
handleExit();
}}
activeOpacity={0.75}
style={{
flex: 1,
backgroundColor: '#e5e7eb',
borderRadius: 12,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
{t('gameOver.exit')}
</Text>
</TouchableOpacity>
</View>
{/* Share section */}
{posted ? (
<View style={{ alignItems: 'center', paddingVertical: 4, flexDirection: 'row', justifyContent: 'center', gap: 6 }}>
@ -439,6 +391,21 @@ export function GameOverScreen({
/>
)}
{/* 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')}
@ -446,60 +413,63 @@ export function GameOverScreen({
) : null}
<View style={{ flexDirection: 'row', gap: 12 }}>
<TouchableOpacity
<Button
title={t('common.cancel')}
onPress={() => { setShareSectionOpen(false); setShareText(''); setPostError(false); }}
activeOpacity={0.7}
style={{
flex: 1,
backgroundColor: '#e5e7eb',
borderRadius: 12,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#374151' }}>
{t('common.cancel')}
</Text>
</TouchableOpacity>
<TouchableOpacity
variant="secondary"
size="md"
style={{ flex: 1 }}
/>
<Button
title={t('gameOver.post_to_community')}
onPress={submitCommunityPost}
disabled={!shareText.trim() || sharing || shareTextLoading}
activeOpacity={0.85}
style={{
flex: 1,
backgroundColor: '#007AFF',
borderRadius: 12,
minHeight: 40,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
gap: 6,
opacity: sharing || !shareText.trim() || shareTextLoading ? 0.55 : 1,
}}
>
{sharing ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Ionicons name="paper-plane-outline" size={16} color="#ffffff" />
)}
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 16, color: '#ffffff' }}>
{t('gameOver.post_to_community')}
</Text>
</TouchableOpacity>
loading={sharing}
variant="primary"
size="md"
icon="paper-plane-outline"
style={{ flex: 1 }}
/>
</View>
</View>
)}
</ScrollView>
</Animated.View>
{/* 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>
);
}

View File

@ -1011,7 +1011,11 @@
"share_loading": "Lyra formuliert...",
"post_to_community": "Posten",
"posted": "Im Community-Feed gepostet",
"post_error": "Posten fehlgeschlagen, nochmal versuchen"
"post_error": "Posten fehlgeschlagen, nochmal versuchen",
"regen_suggestion": "Neuer Vorschlag",
"regen_confirm_title": "Text verwerfen?",
"regen_confirm_body": "Deinen aktuellen Text verwerfen und neuen Vorschlag holen?",
"regen_confirm_ok": "Verwerfen"
},
"alert": {
"error_generic": "Etwas ist schiefgelaufen — versuch es nochmal.",

View File

@ -1011,7 +1011,11 @@
"share_loading": "Lyra is writing...",
"post_to_community": "Post",
"posted": "Posted to the community feed",
"post_error": "Posting failed, please try again"
"post_error": "Posting failed, please try again",
"regen_suggestion": "New suggestion",
"regen_confirm_title": "Discard text?",
"regen_confirm_body": "Discard your current text and fetch a new suggestion?",
"regen_confirm_ok": "Discard"
},
"alert": {
"error_generic": "Something went wrong — please try again.",

View File

@ -1008,7 +1008,11 @@
"share_loading": "Lyra rédige...",
"post_to_community": "Publier",
"posted": "Publié dans le fil communautaire",
"post_error": "Publication échouée, veuillez réessayer"
"post_error": "Publication échouée, veuillez réessayer",
"regen_suggestion": "Nouvelle suggestion",
"regen_confirm_title": "Effacer le texte ?",
"regen_confirm_body": "Effacer votre texte actuel et obtenir une nouvelle suggestion ?",
"regen_confirm_ok": "Effacer"
},
"alert": {
"error_generic": "Une erreur est survenue — veuillez réessayer.",