- ChatBubble: useActionSheet replaces custom Modal (native iOS popup, Android bottom sheet) - DM mode (isDM prop): hides like-count, shows Insta-style heart badge under bubble when liked - Group chat unchanged - Cleanup: remove unused Modal/Platform imports, sheet styles, actionsOpen state - deploy.sh: auto-detect ANDROID_HOME + auto-create local.properties for local Gradle - NEXT_RELEASE.md: DM reactions release note - Includes other staged work across binder-mac, marketing, ops/mdm, ios/
519 lines
18 KiB
TypeScript
519 lines
18 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 [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);
|
|
|
|
// UI mode — kontrolliert welche Buttons im Footer erscheinen (immer max 2)
|
|
// 'default' → [Retry, Exit]
|
|
// 'rating' → [Cancel, Save]
|
|
// 'share' → [Cancel, Post]
|
|
const [mode, setMode] = useState<'default' | 'rating' | 'share'>('default');
|
|
|
|
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);
|
|
setMode('default');
|
|
} catch {
|
|
// endpoint not yet live — silent
|
|
setMode('default');
|
|
} 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);
|
|
setMode('share');
|
|
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);
|
|
setMode('default');
|
|
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 — interaktiv nur im default-Mode (rating-Modus zeigt Stars + feedback unten) */}
|
|
<View style={{ alignItems: 'center', gap: 6 }}>
|
|
<StarRating
|
|
value={rating}
|
|
size="lg"
|
|
interactive={!saved && (mode === 'default' || mode === 'rating')}
|
|
filledColor="#007AFF"
|
|
onChange={(v) => {
|
|
if (saved) return;
|
|
setRating(v);
|
|
// Tap auf Stern im default-Mode → wechselt in Rating-Mode
|
|
if (mode === 'default' && v > 0) setMode('rating');
|
|
}}
|
|
/>
|
|
{saved ? (
|
|
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
|
|
{t('gameOver.rating_saved')}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
{/* Rating-Mode: Feedback Textarea (Save/Cancel sind im Footer) */}
|
|
{mode === 'rating' && !saved ? (
|
|
<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',
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Share-Mode: Lyra-Vorschlag-Textarea + kompakter Regen-Link (Post/Cancel sind im Footer) */}
|
|
{mode === 'share' ? (
|
|
<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',
|
|
}}
|
|
/>
|
|
<TouchableOpacity
|
|
onPress={regenerateShareText}
|
|
disabled={regenLoading || sharing}
|
|
activeOpacity={0.6}
|
|
style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, paddingVertical: 6 }}
|
|
>
|
|
{regenLoading ? (
|
|
<ActivityIndicator size="small" color={colors.textMuted} />
|
|
) : (
|
|
<Ionicons name="refresh-outline" size={14} color={colors.textMuted} />
|
|
)}
|
|
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
|
|
{t('gameOver.regen_suggestion')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</>
|
|
)}
|
|
|
|
{postError ? (
|
|
<Text style={{ fontSize: 12, color: colors.error, fontFamily: 'Nunito_600SemiBold', textAlign: 'center' }}>
|
|
{t('gameOver.post_error')}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
|
|
{/* Default-Mode: Posted-Banner oder Share-Trigger-Link (kein Button) */}
|
|
{mode === 'default' && 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>
|
|
) : null}
|
|
|
|
{mode === 'default' && !posted ? (
|
|
<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>
|
|
) : null}
|
|
</ScrollView>
|
|
|
|
{/* Fixed footer — IMMER genau 2 Buttons, je nach Mode */}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
gap: 12,
|
|
paddingHorizontal: 20,
|
|
paddingTop: 12,
|
|
paddingBottom: insets.bottom + 16,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
}}
|
|
>
|
|
{mode === 'rating' ? (
|
|
<>
|
|
<Button
|
|
title={t('common.cancel')}
|
|
onPress={() => {
|
|
if (saving) return;
|
|
setMode('default');
|
|
setFeedback('');
|
|
setRating(0);
|
|
}}
|
|
variant="secondary"
|
|
size="md"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<Button
|
|
title={t('gameOver.save_rating')}
|
|
onPress={submitRating}
|
|
disabled={rating === 0 || saving}
|
|
loading={saving}
|
|
variant="primary"
|
|
size="md"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</>
|
|
) : mode === 'share' ? (
|
|
<>
|
|
<Button
|
|
title={t('common.cancel')}
|
|
onPress={() => {
|
|
if (sharing) return;
|
|
setMode('default');
|
|
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 }}
|
|
/>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
title={t('gameOver.exit')}
|
|
onPress={() => {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {});
|
|
handleExit();
|
|
}}
|
|
variant="secondary"
|
|
size="md"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<Button
|
|
title={t('gameOver.retry')}
|
|
onPress={() => {
|
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
|
|
onRetry();
|
|
}}
|
|
variant="primary"
|
|
size="md"
|
|
style={{ flex: 1 }}
|
|
/>
|
|
</>
|
|
)}
|
|
</View>
|
|
</Animated.View>
|
|
</KeyboardAvoidingView>
|
|
</Modal>
|
|
);
|
|
}
|