Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
91 changes: 20 additions & 71 deletions app/game.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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;
Expand All @@ -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<ConfettiIntensity>('full');
const [unlockedBadge, setUnlockedBadge] = useState<AchievementDef | null>(null);
const lastCheckedRatingRef = useRef<number>(0);
const handleBadgeToastHide = useCallback(() => setUnlockedBadge(null), []);

// Phase crossfade
const [visiblePhase, setVisiblePhase] = useState<GamePhase>('memorize');
Expand Down Expand Up @@ -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;

Expand All @@ -127,7 +128,7 @@ export default function GameScreen() {
}, [phase]); // eslint-disable-line react-hooks/exhaustive-deps

const handleRestartCurrentLevel = () => {
setHasUsedHint(false);
resetHint();
restartCurrentLevel();
};

Expand All @@ -136,13 +137,13 @@ export default function GameScreen() {
setShowPauseModal(true);
return;
}
setHasUsedHint(false);
resetHint();
startNextLevel();
};

const handlePauseContinue = () => {
setShowPauseModal(false);
setHasUsedHint(false);
resetHint();
startNextLevel();
};

Expand All @@ -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)
Expand All @@ -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 (
Expand Down Expand Up @@ -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={{
Expand Down
12 changes: 9 additions & 3 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ 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,
]}
Expand All @@ -197,7 +199,11 @@ export default function HomeScreen() {
<Text
style={[
styles.dailyChallengeTitle,
{ color: dailyCompleted ? colors.text.secondary : '#D97706' },
{
color: dailyCompleted
? colors.text.secondary
: Colors.accents.dailyChallengeText,
},
]}
>
{t('dailyChallenge.title')}
Expand Down Expand Up @@ -292,7 +298,7 @@ const styles = StyleSheet.create({
gap: Spacing.xs,
},
streakBadge: {
backgroundColor: '#FF6B35',
backgroundColor: Colors.accents.streakBadge,
borderRadius: BorderRadius.round,
paddingHorizontal: Spacing.sm,
paddingVertical: 3,
Expand Down
53 changes: 0 additions & 53 deletions components/AnimatedPrimitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,59 +130,6 @@ export function GlassCard({
return <Animated.View style={[animatedStyle, style]}>{children}</Animated.View>;
}

/**
* 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 (
<AnimatedPressable
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled}
style={[animatedStyle, style]}
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessibilityRole || 'button'}
{...rest}
>
{children}
</AnimatedPressable>
);
}

/**
* Element that scales + fades in when it appears (e.g. feedback text, tooltips).
*/
Expand Down
8 changes: 0 additions & 8 deletions components/__tests__/GalleryScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,6 @@ jest.mock('../../components/AnimatedPrimitives', () => {
return {
AnimatedCard: ({ children, style }: any) => <View style={style}>{children}</View>,
GlassCard: ({ children, style }: any) => <View style={style}>{children}</View>,
AnimatedButton: ({ children, onPress, style, disabled }: any) => {
const { Pressable } = require('react-native');
return (
<Pressable onPress={onPress} style={style} disabled={disabled}>
{children}
</Pressable>
);
},
AnimatedFeedback: ({ children, visible }: any) => (visible ? <View>{children}</View> : null),
};
});
Expand Down
5 changes: 3 additions & 2 deletions components/game/TimerArc.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<View style={styles.container}>
Expand Down Expand Up @@ -100,6 +101,6 @@ const styles = StyleSheet.create({
lineHeight: SIZE,
},
timerTextUrgent: {
color: '#FFD700',
color: Colors.timerArc.warning,
},
});
11 changes: 11 additions & 0 deletions components/game/TimerArc.shared.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 3 additions & 2 deletions components/game/TimerArc.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<View style={styles.container}>
Expand Down Expand Up @@ -79,6 +80,6 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
timerTextUrgent: {
color: '#FFD700',
color: Colors.timerArc.warning,
},
});
17 changes: 17 additions & 0 deletions constants/Colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};

/**
Expand Down
Loading
Loading