From b1354cd479865fd76dd1ed238aeec4288ddae527 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:42:56 +0000 Subject: [PATCH 1/2] refactor: Code-Health-Audit-Befunde beheben (Farb-Tokens, toter Code, game.tsx State-Cluster) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behebt alle drei offenen Befunde aus Issue #334: - TimerArc.native.tsx/.web.tsx referenzieren die Timer-Zustandsfarben jetzt über eine gemeinsame TimerArc.shared.ts + Colors.timerArc-Tokens statt hartkodierter Hex-Duplikate; app/index.tsx nutzt für Daily-Challenge- und Streak-Akzente ebenfalls zentrale Colors.accents-Tokens. - Ungenutzten AnimatedButton-Export aus AnimatedPrimitives.tsx entfernt (inkl. Test-Mock und CLAUDE.md-Referenz). - Confetti-/Badge-Toast-/Hint-Modal-State aus app/game.tsx in einen dedizierten useGameFeedback()-Hook ausgelagert, analog zu useGamePhase/ useTimer. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MbKegWQy8DNKFvzAfVKNbt --- CLAUDE.md | 3 +- app/game.tsx | 91 +++++---------------- app/index.tsx | 6 +- components/AnimatedPrimitives.tsx | 53 ------------ components/__tests__/GalleryScreen.test.tsx | 8 -- components/game/TimerArc.native.tsx | 5 +- components/game/TimerArc.shared.ts | 11 +++ components/game/TimerArc.web.tsx | 5 +- constants/Colors.ts | 17 ++++ services/useGameFeedback.ts | 89 ++++++++++++++++++++ 10 files changed, 147 insertions(+), 141 deletions(-) create mode 100644 components/game/TimerArc.shared.ts create mode 100644 services/useGameFeedback.ts diff --git a/CLAUDE.md b/CLAUDE.md index feedde8..606b3ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ components/ ParentalGate.tsx # Eltern-Sperre für Einstellungen SettingsModal.tsx # Einstellungen-Modal (In-Game) ErrorBoundary.tsx # Fehlerbehandlung für Render-Fehler - AnimatedPrimitives.tsx # AnimatedCard, GlassCard, AnimatedButton, AnimatedFeedback, AnimatedStar, PressableScale, PulseView + AnimatedPrimitives.tsx # AnimatedCard, GlassCard, AnimatedFeedback, AnimatedStar, PressableScale, PulseView AnimatedSplashScreen.tsx # Animierter Splash Screen Badge.tsx # UI-Primitiv: Badge Chip.tsx # UI-Primitiv: Chip @@ -341,7 +341,6 @@ Stand `main`: Phase A, B, C, D und E vollständig abgeschlossen (Lottie-Teil aus | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | | `AnimatedCard` | Fade-in + Slide-up Eingangs-Animation mit Stagger (50 ms/Item) | | `GlassCard` | Glassmorphism + Eingangs-Animation + optionaler Press-Lift (scale 0.97, Spring) — `prefers-reduced-motion`-aware | -| `AnimatedButton` | Scale-Spring bei Press — `prefers-reduced-motion`-aware | | `AnimatedFeedback` | Scale + Fade beim Erscheinen (z.B. Feedback-Text) | | `AnimatedStar` | Spring-Bounce-Pop beim Füllen, Stagger 80 ms/Stern, goldener Textglow — `prefers-reduced-motion`-aware | | `PressableScale` | Generischer Pressable mit Spring-Scale-Down beim Drücken — taktiles Feedback für schmucklose Kacheln, `prefers-reduced-motion`-aware | diff --git a/app/game.tsx b/app/game.tsx index ba9d3c5..c85d046 100644 --- a/app/game.tsx +++ b/app/game.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useScreenLayout } from '@utils/useScreenLayout'; @@ -20,9 +20,7 @@ import TutorialOverlay from '@components/TutorialOverlay'; import { markOnboardingDone } from '@services/OnboardingManager'; import SoundManager from '@services/SoundManager'; import { useGamePhase } from '@services/useGamePhase'; -import { checkAndUnlock, type AchievementDef } from '@services/AchievementManager'; -import { getStreakData } from '@services/StreakManager'; -import storageManager from '@services/StorageManager'; +import { useGameFeedback } from '@services/useGameFeedback'; import { getAgeGroup, getDefaultStrokeWidthForAgeGroup } from '@services/AgeGroupManager'; import MemorizePhase from '@components/game/MemorizePhase'; import DrawPhase from '@components/game/DrawPhase'; @@ -35,7 +33,6 @@ import Animated, { } from 'react-native-reanimated'; import { useReduceMotion } from '../utils/useReduceMotion'; import type { GamePhase, GameVariant } from '../types'; -import type { ConfettiIntensity } from '@components/ConfettiBurst'; // Nach jeweils so vielen abgeschlossenen Leveln wird eine Pause vorgeschlagen (Issue #337). const LEVELS_PER_PAUSE = 5; @@ -50,15 +47,7 @@ export default function GameScreen() { const { screenWidth, isSmall } = layout; const reduceMotion = useReduceMotion(); const [showSettings, setShowSettings] = useState(false); - const [showHintModal, setShowHintModal] = useState(false); - const [hasUsedHint, setHasUsedHint] = useState(false); - const [showConfetti, setShowConfetti] = useState(false); const [showPauseModal, setShowPauseModal] = useState(false); - const [celebrationEnabled, setCelebrationEnabled] = useState(true); - const [confettiIntensity, setConfettiIntensity] = useState('full'); - const [unlockedBadge, setUnlockedBadge] = useState(null); - const lastCheckedRatingRef = useRef(0); - const handleBadgeToastHide = useCallback(() => setUnlockedBadge(null), []); // Phase crossfade const [visiblePhase, setVisiblePhase] = useState('memorize'); @@ -109,6 +98,18 @@ export default function GameScreen() { pack, }); + const { + showHintModal, + setShowHintModal, + hasUsedHint, + resetHint, + onUseHint, + showConfetti, + confettiIntensity, + unlockedBadge, + handleBadgeToastHide, + } = useGameFeedback(userRating); + useEffect(() => { if (phase === visiblePhase) return; @@ -127,7 +128,7 @@ export default function GameScreen() { }, [phase]); // eslint-disable-line react-hooks/exhaustive-deps const handleRestartCurrentLevel = () => { - setHasUsedHint(false); + resetHint(); restartCurrentLevel(); }; @@ -136,13 +137,13 @@ export default function GameScreen() { setShowPauseModal(true); return; } - setHasUsedHint(false); + resetHint(); startNextLevel(); }; const handlePauseContinue = () => { setShowPauseModal(false); - setHasUsedHint(false); + resetHint(); startNextLevel(); }; @@ -152,17 +153,12 @@ export default function GameScreen() { }; const handleRestartFromLevel1 = () => { - setHasUsedHint(false); + resetHint(); restartFromLevel1(); }; useEffect(() => { - async function init() { - await SoundManager.init(); - const enabled = await storageManager.getSetting('celebrationEnabled'); - setCelebrationEnabled(enabled); - } - init(); + SoundManager.init(); }, []); // Altersgerechte Standard-Strichstärke beim Rundenstart (Issue #279, 1.3) @@ -186,53 +182,6 @@ export default function GameScreen() { } }, [isTutorial, userRating]); - useEffect(() => { - if (userRating === 0 || userRating === lastCheckedRatingRef.current) return; - lastCheckedRatingRef.current = userRating; - - if (userRating >= 4 && FeatureFlags.ENABLE_CONFETTI && celebrationEnabled) { - const intensity: ConfettiIntensity = userRating === 5 ? 'full' : 'light'; - const clearAfter = userRating === 5 ? 2700 : 1700; - setConfettiIntensity(intensity); - setShowConfetti(true); - SoundManager.playCelebration(); - const timer = setTimeout(() => setShowConfetti(false), clearAfter); - checkAchievements(userRating); - return () => clearTimeout(timer); - } - checkAchievements(userRating); - }, [userRating, celebrationEnabled]); // eslint-disable-line react-hooks/exhaustive-deps - - const checkAchievements = async (rating: number) => { - try { - const [gallery, progress, streak] = await Promise.all([ - storageManager.getGallery(), - storageManager.getProgress(), - getStreakData(), - ]); - const dailyChallengesCompleted = gallery.filter(g => g.isDailyChallenge).length; - const difficultiesPlayed = Array.from( - new Set( - Object.keys(progress.levels ?? {}) - .map(n => parseInt(n, 10)) - .filter(n => !isNaN(n)) - .map(n => getDifficultyForLevel(n)), - ), - ); - const newly = await checkAndUnlock({ - stars: rating, - galleryCount: gallery.length, - currentStreak: streak.currentStreak, - levelsCompleted: progress.totalLevelsCompleted ?? 0, - dailyChallengesCompleted, - difficultiesPlayed, - }); - if (newly.length > 0) setUnlockedBadge(newly[0]); - } catch { - // best-effort - } - }; - const levelName = currentLang === 'en' ? currentImage?.displayNameEn : currentImage?.displayName; return ( @@ -427,7 +376,7 @@ export default function GameScreen() { currentImage={currentImage} currentLang={currentLang} hasUsedHint={hasUsedHint} - onUseHint={() => setHasUsedHint(true)} + onUseHint={onUseHint} onShowHintModal={() => setShowHintModal(true)} drawing={drawing} layout={{ diff --git a/app/index.tsx b/app/index.tsx index 5749a96..3a67ccc 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -183,7 +183,7 @@ export default function HomeScreen() { styles.dailyChallengeButton, { backgroundColor: colors.surface, - borderColor: dailyCompleted ? colors.text.light : '#F59E0B', + borderColor: dailyCompleted ? colors.text.light : Colors.accents.dailyChallengeBorder, }, dailyCompleted && styles.dailyChallengeCompleted, ]} @@ -197,7 +197,7 @@ export default function HomeScreen() { {t('dailyChallenge.title')} @@ -292,7 +292,7 @@ const styles = StyleSheet.create({ gap: Spacing.xs, }, streakBadge: { - backgroundColor: '#FF6B35', + backgroundColor: Colors.accents.streakBadge, borderRadius: BorderRadius.round, paddingHorizontal: Spacing.sm, paddingVertical: 3, diff --git a/components/AnimatedPrimitives.tsx b/components/AnimatedPrimitives.tsx index 88b2f2d..c9ce41f 100644 --- a/components/AnimatedPrimitives.tsx +++ b/components/AnimatedPrimitives.tsx @@ -130,59 +130,6 @@ export function GlassCard({ return {children}; } -/** - * Button with scale-spring on press. - */ -export function AnimatedButton({ - onPress, - style, - disabled, - children, - accessibilityLabel, - accessibilityRole, - ...rest -}: { - onPress?: () => void; - style?: ViewStyle | ViewStyle[]; - disabled?: boolean; - children: React.ReactNode; - accessibilityLabel?: string; - accessibilityRole?: 'button' | 'link'; - [key: string]: any; -}) { - const reduceMotion = useReduceMotion(); - const scale = useSharedValue(1); - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - const handlePressIn = () => { - if (reduceMotion) return; - scale.value = withSpring(0.95, { damping: 15, stiffness: 300 }); - }; - - const handlePressOut = () => { - if (reduceMotion) return; - scale.value = withSpring(1, { damping: 15, stiffness: 300 }); - }; - - return ( - - {children} - - ); -} - /** * Element that scales + fades in when it appears (e.g. feedback text, tooltips). */ diff --git a/components/__tests__/GalleryScreen.test.tsx b/components/__tests__/GalleryScreen.test.tsx index 74649d3..f432ed1 100644 --- a/components/__tests__/GalleryScreen.test.tsx +++ b/components/__tests__/GalleryScreen.test.tsx @@ -40,14 +40,6 @@ jest.mock('../../components/AnimatedPrimitives', () => { return { AnimatedCard: ({ children, style }: any) => {children}, GlassCard: ({ children, style }: any) => {children}, - AnimatedButton: ({ children, onPress, style, disabled }: any) => { - const { Pressable } = require('react-native'); - return ( - - {children} - - ); - }, AnimatedFeedback: ({ children, visible }: any) => (visible ? {children} : null), }; }); diff --git a/components/game/TimerArc.native.tsx b/components/game/TimerArc.native.tsx index 1a8fb2f..4ac0aba 100644 --- a/components/game/TimerArc.native.tsx +++ b/components/game/TimerArc.native.tsx @@ -5,6 +5,7 @@ import { Easing, useSharedValue, useDerivedValue, withTiming } from 'react-nativ import { useReduceMotion } from '../../utils/useReduceMotion'; import Colors from '../../constants/Colors'; import { FontSize, FontWeight } from '../../constants/Layout'; +import { getTimerArcColor } from './TimerArc.shared'; const SIZE = 80; const STROKE_WIDTH = 6; @@ -45,7 +46,7 @@ export function TimerArc({ timeRemaining, totalTime }: TimerArcProps) { }); const isUrgent = timeRemaining <= 3; - const arcColor = timeRemaining <= 1 ? '#FF6B6B' : timeRemaining <= 3 ? '#FFD700' : '#FFFFFF'; + const arcColor = getTimerArcColor(timeRemaining); return ( @@ -100,6 +101,6 @@ const styles = StyleSheet.create({ lineHeight: SIZE, }, timerTextUrgent: { - color: '#FFD700', + color: Colors.timerArc.warning, }, }); diff --git a/components/game/TimerArc.shared.ts b/components/game/TimerArc.shared.ts new file mode 100644 index 0000000..dcb74ec --- /dev/null +++ b/components/game/TimerArc.shared.ts @@ -0,0 +1,11 @@ +import Colors from '../../constants/Colors'; + +/** + * Shared urgency-color logic for TimerArc.native.tsx and TimerArc.web.tsx + * (previously duplicated verbatim in both files, Issue #334). + */ +export function getTimerArcColor(timeRemaining: number): string { + if (timeRemaining <= 1) return Colors.timerArc.critical; + if (timeRemaining <= 3) return Colors.timerArc.warning; + return Colors.timerArc.normal; +} diff --git a/components/game/TimerArc.web.tsx b/components/game/TimerArc.web.tsx index 90095f7..0769a0c 100644 --- a/components/game/TimerArc.web.tsx +++ b/components/game/TimerArc.web.tsx @@ -3,6 +3,7 @@ import { View, Text, StyleSheet } from 'react-native'; import Svg, { Circle } from 'react-native-svg'; import Colors from '../../constants/Colors'; import { FontSize, FontWeight } from '../../constants/Layout'; +import { getTimerArcColor } from './TimerArc.shared'; const SIZE = 80; const STROKE_WIDTH = 6; @@ -19,7 +20,7 @@ export function TimerArc({ timeRemaining, totalTime }: TimerArcProps) { const progress = totalTime > 0 ? Math.max(0, timeRemaining) / totalTime : 0; const strokeDashoffset = CIRCUMFERENCE * (1 - progress); const isUrgent = timeRemaining <= 3; - const arcColor = timeRemaining <= 1 ? '#FF6B6B' : timeRemaining <= 3 ? '#FFD700' : '#FFFFFF'; + const arcColor = getTimerArcColor(timeRemaining); return ( @@ -79,6 +80,6 @@ const styles = StyleSheet.create({ textAlign: 'center', }, timerTextUrgent: { - color: '#FFD700', + color: Colors.timerArc.warning, }, }); diff --git a/constants/Colors.ts b/constants/Colors.ts index cd20998..107d8b0 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -114,6 +114,23 @@ export const Colors = { 4: '#E67E22', // Dunkles Orange - Schwierig 5: '#E74C3C', // Rot - Sehr schwierig }, + + // Timer-Zustandsfarben (TimerArc.native.tsx / TimerArc.web.tsx) — referenzieren + // bewusst dieselben Hex-Werte wie gradient.warm[1] / stars.filled, damit eine + // künftige Palettenänderung nur an einer Stelle nachgezogen werden muss (Issue #334). + timerArc: { + critical: '#FF6B6B', + warning: '#FFD700', + normal: '#FFFFFF', + }, + + // UI-Akzentfarben außerhalb des Zeichnungskontexts (Home-Screen), zentralisiert + // statt als Hex-Literal an der Verwendungsstelle (Issue #334). + accents: { + dailyChallengeBorder: '#F59E0B', + dailyChallengeText: '#D97706', + streakBadge: '#FF6B35', + }, }; /** diff --git a/services/useGameFeedback.ts b/services/useGameFeedback.ts new file mode 100644 index 0000000..ef42445 --- /dev/null +++ b/services/useGameFeedback.ts @@ -0,0 +1,89 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import storageManager from '@services/StorageManager'; +import SoundManager from '@services/SoundManager'; +import { checkAndUnlock, type AchievementDef } from '@services/AchievementManager'; +import { getStreakData } from '@services/StreakManager'; +import { getDifficultyForLevel } from '@services/LevelManager'; +import { FeatureFlags } from '../constants/featureFlags'; +import type { ConfettiIntensity } from '@components/ConfettiBurst'; + +/** + * Bündelt den Overlay-/Feedback-State der Ergebnis-Phase (Hint-Modal, Confetti, + * Badge-Unlock-Toast), der vorher direkt in app/game.tsx lag (Issue #334). + */ +export function useGameFeedback(userRating: number) { + const [showHintModal, setShowHintModal] = useState(false); + const [hasUsedHint, setHasUsedHint] = useState(false); + const [showConfetti, setShowConfetti] = useState(false); + const [celebrationEnabled, setCelebrationEnabled] = useState(true); + const [confettiIntensity, setConfettiIntensity] = useState('full'); + const [unlockedBadge, setUnlockedBadge] = useState(null); + const lastCheckedRatingRef = useRef(0); + + const handleBadgeToastHide = useCallback(() => setUnlockedBadge(null), []); + const resetHint = useCallback(() => setHasUsedHint(false), []); + + useEffect(() => { + storageManager.getSetting('celebrationEnabled').then(setCelebrationEnabled); + }, []); + + const checkAchievements = useCallback(async (rating: number) => { + try { + const [gallery, progress, streak] = await Promise.all([ + storageManager.getGallery(), + storageManager.getProgress(), + getStreakData(), + ]); + const dailyChallengesCompleted = gallery.filter(g => g.isDailyChallenge).length; + const difficultiesPlayed = Array.from( + new Set( + Object.keys(progress.levels ?? {}) + .map(n => parseInt(n, 10)) + .filter(n => !isNaN(n)) + .map(n => getDifficultyForLevel(n)), + ), + ); + const newly = await checkAndUnlock({ + stars: rating, + galleryCount: gallery.length, + currentStreak: streak.currentStreak, + levelsCompleted: progress.totalLevelsCompleted ?? 0, + dailyChallengesCompleted, + difficultiesPlayed, + }); + if (newly.length > 0) setUnlockedBadge(newly[0]); + } catch { + // best-effort + } + }, []); + + useEffect(() => { + if (userRating === 0 || userRating === lastCheckedRatingRef.current) return; + lastCheckedRatingRef.current = userRating; + + if (userRating >= 4 && FeatureFlags.ENABLE_CONFETTI && celebrationEnabled) { + const intensity: ConfettiIntensity = userRating === 5 ? 'full' : 'light'; + const clearAfter = userRating === 5 ? 2700 : 1700; + setConfettiIntensity(intensity); + setShowConfetti(true); + SoundManager.playCelebration(); + const timer = setTimeout(() => setShowConfetti(false), clearAfter); + checkAchievements(userRating); + return () => clearTimeout(timer); + } + checkAchievements(userRating); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userRating, celebrationEnabled]); + + return { + showHintModal, + setShowHintModal, + hasUsedHint, + resetHint, + onUseHint: useCallback(() => setHasUsedHint(true), []), + showConfetti, + confettiIntensity, + unlockedBadge, + handleBadgeToastHide, + }; +} From 827c4ca792987778ee63310140bad19ec1cdb632 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 20:43:20 +0000 Subject: [PATCH 2/2] =?UTF-8?q?style:=20prettier-Formatierung=20f=C3=BCr?= =?UTF-8?q?=20app/index.tsx=20nachziehen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MbKegWQy8DNKFvzAfVKNbt --- app/index.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/index.tsx b/app/index.tsx index 3a67ccc..98e9f05 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -183,7 +183,9 @@ export default function HomeScreen() { styles.dailyChallengeButton, { backgroundColor: colors.surface, - borderColor: dailyCompleted ? colors.text.light : Colors.accents.dailyChallengeBorder, + borderColor: dailyCompleted + ? colors.text.light + : Colors.accents.dailyChallengeBorder, }, dailyCompleted && styles.dailyChallengeCompleted, ]} @@ -197,7 +199,11 @@ export default function HomeScreen() { {t('dailyChallenge.title')}