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:
parent
d28d1f145d
commit
964dc2b6e0
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user