diff --git a/app/_layout.tsx b/app/_layout.tsx index 5c6a73a..f10b42d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -16,6 +16,7 @@ import { Nunito_700Bold, Nunito_800ExtraBold, } from '@expo-google-fonts/nunito'; +import { SITE_URL, PLAY_STORE_URL } from '../constants/ExternalLinks'; // Initialize Sentry as early as possible (no-op on web or without DSN) initSentry(); @@ -23,7 +24,6 @@ initSentry(); // Privacy-freundliches, minimales Analytics (no-op ohne EXPO_PUBLIC_PLAUSIBLE_DOMAIN) initAnalytics(); -const SITE_URL = 'https://s540d.github.io/DrawFromMemory/'; const OG_IMAGE_URL = `${SITE_URL}feature-graphic.png`; const SEO_TITLE = 'Merke und Male – Gedächtnistraining für Kinder'; const SEO_DESCRIPTION = @@ -99,7 +99,7 @@ function RootLayoutContent() { price: '0', priceCurrency: 'EUR', }, - installUrl: 'https://play.google.com/store/apps/details?id=com.s540d.merkeundmale', + installUrl: PLAY_STORE_URL, })} diff --git a/app/gallery.tsx b/app/gallery.tsx index c5c9a03..3b9a4f3 100644 --- a/app/gallery.tsx +++ b/app/gallery.tsx @@ -262,7 +262,7 @@ const styles = StyleSheet.create({ }, dailyBadge: { alignSelf: 'flex-start', - backgroundColor: '#F59E0B', + backgroundColor: Colors.accents.dailyChallenge, borderRadius: BorderRadius.sm, paddingHorizontal: 6, paddingVertical: 2, diff --git a/app/game.tsx b/app/game.tsx index c85d046..19a2e63 100644 --- a/app/game.tsx +++ b/app/game.tsx @@ -1,16 +1,16 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform } from 'react-native'; +import { View, Text, StyleSheet, TouchableOpacity, Modal } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useScreenLayout } from '@utils/useScreenLayout'; import { useRouter, useLocalSearchParams } from 'expo-router'; -import { getTotalLevels, getDifficultyForLevel } from '@services/LevelManager'; +import { getTotalLevels } from '@services/LevelManager'; import { useTranslation, getLanguage } from '@services/i18n'; import { useTheme } from '@services/ThemeContext'; import Colors from '../constants/Colors'; import { Spacing, FontSize, FontWeight, BorderRadius } from '../constants/Layout'; import { FeatureFlags } from '../constants/featureFlags'; import LevelImageDisplay from '@components/LevelImageDisplay'; -import DrawingCanvas, { useDrawingCanvas } from '@components/DrawingCanvas'; +import { useDrawingCanvas } from '@components/DrawingCanvas'; import SettingsModal from '@components/SettingsModal'; import { ErrorBoundary } from '@components/ErrorBoundary'; import ConfettiBurst from '@components/ConfettiBurst'; @@ -87,7 +87,6 @@ export default function GameScreen() { handleRatingSubmit, saveToGallery, startReplay, - restartCurrentLevel, startNextLevel, restartFromLevel1, } = useGamePhase({ @@ -127,11 +126,6 @@ export default function GameScreen() { }); }, [phase]); // eslint-disable-line react-hooks/exhaustive-deps - const handleRestartCurrentLevel = () => { - resetHint(); - restartCurrentLevel(); - }; - const handleStartNextLevel = () => { if (levelNumber % LEVELS_PER_PAUSE === 0 && levelNumber < totalLevels) { setShowPauseModal(true); diff --git a/app/levels.tsx b/app/levels.tsx index 09acb71..8f6eaf5 100644 --- a/app/levels.tsx +++ b/app/levels.tsx @@ -64,14 +64,14 @@ export default function LevelsScreen() { useEffect(() => { let mounted = true; - Promise.all( - levels.map(level => - storageManager - .getLevelRating(level.number) - .then(r => [level.number, r] as [number, number | null]), - ), - ).then(entries => { - if (mounted) setRatings(Object.fromEntries(entries)); + // Ein einziger Progress-Read statt einem getLevelRating()-Read pro Level + storageManager.getProgress().then(progress => { + if (!mounted) return; + setRatings( + Object.fromEntries( + levels.map(level => [level.number, progress.levels[level.number]?.bestRating ?? null]), + ), + ); }); return () => { mounted = false; diff --git a/app/settings.tsx b/app/settings.tsx index 395f4f0..6d31c4d 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -3,8 +3,7 @@ import { View, Text, StyleSheet, TouchableOpacity, ScrollView } from 'react-nati import { useRouter } from 'expo-router'; import { useTranslation } from '@services/i18n'; import { useTheme } from '@services/ThemeContext'; -import Colors from '../constants/Colors'; -import { Spacing, FontSize, FontWeight, BorderRadius } from '../constants/Layout'; +import { Spacing, FontSize, FontWeight } from '../constants/Layout'; import SettingsModal from '@components/SettingsModal'; /** diff --git a/components/AnimatedHero.tsx b/components/AnimatedHero.tsx deleted file mode 100644 index 10b4604..0000000 --- a/components/AnimatedHero.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { useEffect } from 'react'; -import { StyleSheet, Text, View, useWindowDimensions } from 'react-native'; -import { useReduceMotion } from '../utils/useReduceMotion'; -import Animated, { - cancelAnimation, - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withSequence, - withTiming, -} from 'react-native-reanimated'; - -/** - * Animated hero element for the Home Screen. - * Shows brain + pencil emojis with looping animations. - * Respects prefers-reduced-motion: static emoji fallback when enabled. - */ -export function AnimatedHero() { - const { height } = useWindowDimensions(); - const emojiSize = height < 600 ? 44 : height < 750 ? 54 : 64; - - const brainScale = useSharedValue(1); - const pencilRotate = useSharedValue(0); - const pencilTranslateX = useSharedValue(0); - const sparkleOpacity = useSharedValue(0.2); - - const reduceMotion = useReduceMotion(); - - useEffect(() => { - if (reduceMotion) { - cancelAnimation(brainScale); - cancelAnimation(pencilRotate); - cancelAnimation(pencilTranslateX); - cancelAnimation(sparkleOpacity); - brainScale.value = 1; - pencilRotate.value = 0; - pencilTranslateX.value = 0; - sparkleOpacity.value = 0.2; - return; - } - - brainScale.value = withRepeat( - withSequence( - withTiming(1.08, { duration: 900, easing: Easing.inOut(Easing.ease) }), - withTiming(1.0, { duration: 900, easing: Easing.inOut(Easing.ease) }), - ), - -1, - false, - ); - - pencilRotate.value = withRepeat( - withSequence( - withTiming(-10, { duration: 600, easing: Easing.inOut(Easing.ease) }), - withTiming(10, { duration: 600, easing: Easing.inOut(Easing.ease) }), - ), - -1, - false, - ); - - pencilTranslateX.value = withRepeat( - withSequence( - withTiming(-6, { duration: 600, easing: Easing.inOut(Easing.ease) }), - withTiming(6, { duration: 600, easing: Easing.inOut(Easing.ease) }), - ), - -1, - false, - ); - - sparkleOpacity.value = withRepeat( - withSequence( - withTiming(1, { duration: 1200, easing: Easing.inOut(Easing.ease) }), - withTiming(0.2, { duration: 1200, easing: Easing.inOut(Easing.ease) }), - ), - -1, - false, - ); - }, [reduceMotion, brainScale, pencilRotate, pencilTranslateX, sparkleOpacity]); - - const brainAnimStyle = useAnimatedStyle(() => ({ - transform: [{ scale: brainScale.value }], - })); - - const pencilAnimStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: pencilTranslateX.value }, { rotate: `${pencilRotate.value}deg` }], - })); - - const sparkleAnimStyle = useAnimatedStyle(() => ({ - opacity: sparkleOpacity.value, - })); - - return ( - - - - 🧠 - - - ✏️ - - - - ✨ - - ✨ - - - ); -} - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - emoji: { - fontSize: 68, - }, - sparkleRow: { - flexDirection: 'row', - marginTop: 6, - }, - sparkle: { - fontSize: 20, - }, -}); - -export default AnimatedHero; diff --git a/components/Badge.tsx b/components/Badge.tsx index aa13ac6..3b1443c 100644 --- a/components/Badge.tsx +++ b/components/Badge.tsx @@ -17,14 +17,6 @@ const VARIANT_COLORS: Record = { difficulty: Colors.primary, }; -const DIFFICULTY_COLORS: Record = { - 1: Colors.difficulty[1], - 2: Colors.difficulty[2], - 3: Colors.difficulty[3], - 4: Colors.difficulty[4], - 5: Colors.difficulty[5], -}; - /** * Badge – kleines Label für Status, Schwierigkeit oder Kategorien. * @@ -48,7 +40,7 @@ export function Badge({ }) { const backgroundColor = variant === 'difficulty' && difficulty !== undefined - ? (DIFFICULTY_COLORS[difficulty] ?? Colors.primary) + ? ((Colors.difficulty as Record)[difficulty] ?? Colors.primary) : VARIANT_COLORS[variant]; const containerStyle: ViewStyle[] = [ diff --git a/components/BadgesModal.tsx b/components/BadgesModal.tsx index faee305..938a99c 100644 --- a/components/BadgesModal.tsx +++ b/components/BadgesModal.tsx @@ -55,7 +55,7 @@ export default function BadgesModal({ visible, onClose }: Props) { style={[ styles.row, { - backgroundColor: colors.surface ?? Colors.surface, + backgroundColor: colors.surface, opacity: isUnlocked ? 1 : 0.45, borderColor: isUnlocked ? Colors.primary : 'transparent', }, diff --git a/components/DrawingCanvas.native.tsx b/components/DrawingCanvas.native.tsx index 9dff4c7..0e423e1 100644 --- a/components/DrawingCanvas.native.tsx +++ b/components/DrawingCanvas.native.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { View, Text, @@ -169,6 +169,25 @@ function SkiaFallback({ ); } +function createSkiaPath( + points: { x: number; y: number }[], + color: string, + strokeW: number, + scale: number = 1, + offsetX: number = 0, + offsetY: number = 0, +) { + if (points.length < 2) return null; + + const path = SkiaModule.Path.Make(); + path.moveTo(points[0].x * scale + offsetX, points[0].y * scale + offsetY); + for (let i = 1; i < points.length; i++) { + path.lineTo(points[i].x * scale + offsetX, points[i].y * scale + offsetY); + } + + return { path, color, width: strokeW * scale }; +} + export default function DrawingCanvas({ width: propWidth, height = 400, @@ -269,25 +288,6 @@ export default function DrawingCanvas({ return { scale, offsetX, offsetY }; }; - const createSkiaPath = ( - points: { x: number; y: number }[], - color: string, - strokeW: number, - scale: number = 1, - offsetX: number = 0, - offsetY: number = 0, - ) => { - if (points.length < 2) return null; - - const path = SkiaModule.Path.Make(); - path.moveTo(points[0].x * scale + offsetX, points[0].y * scale + offsetY); - for (let i = 1; i < points.length; i++) { - path.lineTo(points[i].x * scale + offsetX, points[i].y * scale + offsetY); - } - - return { path, color, width: strokeW * scale }; - }; - const { scale, offsetX, offsetY } = !onDrawingChange ? getScaledPaths() : { scale: 1, offsetX: 0, offsetY: 0 }; @@ -313,6 +313,37 @@ export default function DrawingCanvas({ } }, [nativePaths, width, height, scale, offsetX, offsetY, hasFillPaths]); + // Fertige Striche nur neu bauen, wenn sich Pfade oder Skalierung ändern — + // nicht bei jedem Touch-Move des gerade gezeichneten Strichs. + const strokeElements = useMemo( + () => + SkiaModule + ? nativePaths.map((pathData, index) => { + const skiaPath = createSkiaPath( + pathData.points, + pathData.color, + pathData.strokeWidth, + scale, + offsetX, + offsetY, + ); + if (!skiaPath) return null; + return ( + + ); + }) + : null, + [nativePaths, scale, offsetX, offsetY], + ); + if (!SkiaCanvas || !SkiaModule) { return ( )), )} - {nativePaths.map((pathData, index) => { - const skiaPath = createSkiaPath( - pathData.points, - pathData.color, - pathData.strokeWidth, - scale, - offsetX, - offsetY, - ); - if (!skiaPath) return null; - return ( - - ); - })} + {strokeElements} ) : ( // No fills – render stroke paths directly as Skia paths (faster, no CPU round-trip) - nativePaths.map((pathData, index) => { - const skiaPath = createSkiaPath( - pathData.points, - pathData.color, - pathData.strokeWidth, - scale, - offsetX, - offsetY, - ); - if (!skiaPath) return null; - return ( - - ); - }) + strokeElements )} {onDrawingChange && diff --git a/components/DrawingCanvas.web.tsx b/components/DrawingCanvas.web.tsx index d0ed72e..eaa58f4 100644 --- a/components/DrawingCanvas.web.tsx +++ b/components/DrawingCanvas.web.tsx @@ -55,7 +55,7 @@ export default function DrawingCanvas({ useEffect(() => { if (!canvasRef.current) return; const canvas = canvasRef.current; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return; ctx.clearRect(0, 0, width, height); diff --git a/components/LevelImageDisplay.tsx b/components/LevelImageDisplay.tsx index 61f8987..00cd863 100644 --- a/components/LevelImageDisplay.tsx +++ b/components/LevelImageDisplay.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; import Svg, { Rect, Line } from 'react-native-svg'; import { LEVEL_IMAGE_RENDERERS } from './levelImages/registry'; @@ -218,27 +218,34 @@ export default function LevelImageDisplay({ const svgSize = size; const viewBox = '0 0 200 200'; - let svgElement = renderSvgForImage(image, svgSize, viewBox); + // SVG-Baum (inkl. Outline-/Dark-Transformation) nur neu bauen, wenn sich Bild, + // Größe, Modus oder Theme ändern — nicht bei jedem Timer-Tick der Merke-Phase. + const svgElement = useMemo(() => { + const element = renderSvgForImage(image, svgSize, viewBox); + if (!element) return null; + const children = (element.props as React.ComponentProps).children; + if (mode === 'outline') { + const outlineColor = theme === 'dark' ? OUTLINE_COLOR_DARK : OUTLINE_COLOR_LIGHT; + return React.cloneElement( + element, + {}, + React.Children.map(children, child => toOutlineElement(child, outlineColor)), + ); + } + if (theme === 'dark') { + return React.cloneElement( + element, + {}, + React.Children.map(children, fixUnfilledStrokeForDarkTheme), + ); + } + return element; + }, [image, svgSize, viewBox, mode, theme]); if (!svgElement) { return ; } - if (mode === 'outline') { - const outlineColor = theme === 'dark' ? OUTLINE_COLOR_DARK : OUTLINE_COLOR_LIGHT; - const outlinedChildren = React.Children.map( - (svgElement.props as React.ComponentProps).children, - child => toOutlineElement(child, outlineColor), - ); - svgElement = React.cloneElement(svgElement, {}, outlinedChildren); - } else if (theme === 'dark') { - const fixedChildren = React.Children.map( - (svgElement.props as React.ComponentProps).children, - fixUnfilledStrokeForDarkTheme, - ); - svgElement = React.cloneElement(svgElement, {}, fixedChildren); - } - const containerStyle = [ styles.container, { width: svgSize, height: svgSize }, diff --git a/components/ParentDashboard.tsx b/components/ParentDashboard.tsx index b091716..0eee14b 100644 --- a/components/ParentDashboard.tsx +++ b/components/ParentDashboard.tsx @@ -55,7 +55,7 @@ export default function ParentDashboard({ visible, onClose }: Props) { }; }, [visible]); - const cardBg = colors.surface ?? Colors.surface; + const cardBg = colors.surface; return ( diff --git a/components/QuickStatsCards.tsx b/components/QuickStatsCards.tsx index cdb17c4..67cf8eb 100644 --- a/components/QuickStatsCards.tsx +++ b/components/QuickStatsCards.tsx @@ -5,6 +5,7 @@ import { useTheme } from '@services/ThemeContext'; import { useTranslation } from '@services/i18n'; import storageManager from '@services/StorageManager'; import { getTotalLevels } from '@services/LevelManager'; +import { getTotalStars } from '@services/MascotManager'; import { FontSize, FontFamily } from '../constants/Layout'; interface Stats { @@ -28,8 +29,10 @@ export default function QuickStatsCards() { let mounted = true; storageManager.getProgress().then(progress => { if (!mounted) return; - const totalStars = Object.values(progress.levels).reduce((sum, l) => sum + l.bestRating, 0); - setStats({ totalStars, levelsCompleted: progress.totalLevelsCompleted }); + setStats({ + totalStars: getTotalStars(progress), + levelsCompleted: progress.totalLevelsCompleted, + }); }); return () => { mounted = false; diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 563d0cc..ae07353 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -51,8 +51,10 @@ export default function SettingsModal({ visible, onClose, embedded = false }: Se useEffect(() => { setCurrentLang(getLanguage()); setCurrentTheme(themeSetting); - storageManager.getSetting('soundEnabled').then(setSoundEnabled); - storageManager.getSetting('celebrationEnabled').then(setCelebrationEnabled); + storageManager.getSettings().then(settings => { + setSoundEnabled(settings.soundEnabled); + setCelebrationEnabled(settings.celebrationEnabled); + }); getAgeGroup().then(setCurrentAgeGroup); }, [visible, themeSetting]); diff --git a/components/TutorialOverlay.tsx b/components/TutorialOverlay.tsx index e63b535..26de529 100644 --- a/components/TutorialOverlay.tsx +++ b/components/TutorialOverlay.tsx @@ -21,7 +21,6 @@ interface Props { onDismiss: () => void; } -const TOTAL_STEPS = 3; const PHASE_ORDER: GamePhase[] = ['memorize', 'draw', 'result']; const PHASE_CONFIG: Record = { diff --git a/components/__tests__/AnimatedHero.test.tsx b/components/__tests__/AnimatedHero.test.tsx deleted file mode 100644 index 2a95c80..0000000 --- a/components/__tests__/AnimatedHero.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import { render, act } from '@testing-library/react-native'; - -jest.mock('../../utils/useReduceMotion', () => ({ - useReduceMotion: jest.fn(() => false), -})); - -jest.mock('react-native-reanimated', () => { - const { View } = require('react-native'); - return { - __esModule: true, - default: { - View, - createAnimatedComponent: (c: any) => c, - }, - cancelAnimation: jest.fn(), - useSharedValue: (v: any) => ({ value: v }), - useAnimatedStyle: (_fn: any) => ({}), - withTiming: (v: any) => v, - withRepeat: jest.fn((v: any) => v), - withSequence: (...args: any[]) => args[args.length - 1], - Easing: { - ease: (t: number) => t, - inOut: (fn: any) => fn, - out: (fn: any) => fn, - }, - }; -}); - -const Reanimated = require('react-native-reanimated'); -const withRepeatMock = Reanimated.withRepeat as jest.Mock; - -import { AnimatedHero } from '../AnimatedHero'; -import { Text } from 'react-native'; - -const getAllTexts = (getAllByType: (t: any) => any[]) => - getAllByType(Text) - .map((n: any) => n.props.children) - .flat() - .map(String); - -describe('AnimatedHero', () => { - beforeEach(() => { - withRepeatMock.mockClear(); - }); - - it('renders without crashing', async () => { - expect(() => render()).not.toThrow(); - await act(async () => {}); - }); - - it('renders brain and pencil emojis', async () => { - const { UNSAFE_getAllByType } = render(); - await act(async () => {}); - const texts = getAllTexts(UNSAFE_getAllByType); - expect(texts).toContain('🧠'); - expect(texts).toContain('✏️'); - }); - - it('starts repeating animations when reduced motion is off', async () => { - render(); - await act(async () => {}); - - expect(withRepeatMock).toHaveBeenCalled(); - }); - - it('does not start animations when prefers-reduced-motion is enabled', async () => { - const { useReduceMotion } = require('../../utils/useReduceMotion'); - (useReduceMotion as jest.Mock).mockReturnValueOnce(true); - - render(); - await act(async () => {}); - - expect(withRepeatMock).not.toHaveBeenCalled(); - }); -}); diff --git a/components/__tests__/HomeScreen.test.tsx b/components/__tests__/HomeScreen.test.tsx index 08721fe..02d805e 100644 --- a/components/__tests__/HomeScreen.test.tsx +++ b/components/__tests__/HomeScreen.test.tsx @@ -84,11 +84,6 @@ jest.mock('../../components/FloatingStars', () => { return { FloatingStars: () => }; }); -jest.mock('../../components/AnimatedHero', () => { - const { View } = require('react-native'); - return { AnimatedHero: () => }; -}); - jest.mock('../../components/OnboardingModal', () => { const { View } = require('react-native'); return { __esModule: true, default: () => }; diff --git a/components/__tests__/LevelsScreen.test.tsx b/components/__tests__/LevelsScreen.test.tsx index 942c458..346474b 100644 --- a/components/__tests__/LevelsScreen.test.tsx +++ b/components/__tests__/LevelsScreen.test.tsx @@ -57,10 +57,17 @@ jest.mock('../../services/LevelManager', () => ({ jest.mock('../../services/StorageManager', () => ({ __esModule: true, default: { - getLevelRating: jest.fn(), + getProgress: jest.fn(), }, })); +const progressWith = (rating: number | null) => ({ + levels: + rating === null + ? {} + : Object.fromEntries([1, 2, 3].map(n => [n, { levelNumber: n, bestRating: rating }])), +}); + jest.mock('../../components/AnimatedPrimitives', () => { const { View } = require('react-native'); return { @@ -94,11 +101,11 @@ describe('LevelsScreen', () => { beforeEach(() => { storageManager = require('../../services/StorageManager').default; - storageManager.getLevelRating.mockReset(); + storageManager.getProgress.mockReset(); }); it('shows only empty stars for levels not yet played', async () => { - storageManager.getLevelRating.mockResolvedValue(null); + storageManager.getProgress.mockResolvedValue(progressWith(null)); const { UNSAFE_getAllByProps } = render(); await act(async () => {}); const emptyStars = getStarNodes(UNSAFE_getAllByProps, 'star-empty'); @@ -107,9 +114,9 @@ describe('LevelsScreen', () => { expect(filledStars.length).toBe(0); }); - it('shows filled stars based on getLevelRating result', async () => { + it('shows filled stars based on stored bestRating', async () => { // rating 5 → Math.ceil(5/2) = 3 filled stars per level - storageManager.getLevelRating.mockResolvedValue(5); + storageManager.getProgress.mockResolvedValue(progressWith(5)); const { UNSAFE_getAllByProps } = render(); await act(async () => {}); const filledStars = getStarNodes(UNSAFE_getAllByProps, 'star-filled'); @@ -118,17 +125,15 @@ describe('LevelsScreen', () => { expect(emptyStars.length).toBe(0); }); - it('calls getLevelRating for each level', async () => { - storageManager.getLevelRating.mockResolvedValue(null); + it('reads progress only once for all levels', async () => { + storageManager.getProgress.mockResolvedValue(progressWith(null)); render(); await act(async () => {}); - expect(storageManager.getLevelRating).toHaveBeenCalledWith(1); - expect(storageManager.getLevelRating).toHaveBeenCalledWith(2); - expect(storageManager.getLevelRating).toHaveBeenCalledWith(3); + expect(storageManager.getProgress).toHaveBeenCalledTimes(1); }); it('renders a star container for each level', async () => { - storageManager.getLevelRating.mockResolvedValue(null); + storageManager.getProgress.mockResolvedValue(progressWith(null)); const { UNSAFE_getAllByProps } = render(); await act(async () => {}); const containers = [1, 2, 3].map(n => getStarNodes(UNSAFE_getAllByProps, `stars-level-${n}`)); @@ -137,7 +142,7 @@ describe('LevelsScreen', () => { it('maps rating correctly: 3 → 2 filled stars per level', async () => { // rating 3 → Math.ceil(3/2) = 2 filled stars per level - storageManager.getLevelRating.mockResolvedValue(3); + storageManager.getProgress.mockResolvedValue(progressWith(3)); const { UNSAFE_getAllByProps } = render(); await act(async () => {}); const filledStars = getStarNodes(UNSAFE_getAllByProps, 'star-filled'); diff --git a/constants/Colors.ts b/constants/Colors.ts index f067ff3..dc1aa12 100644 --- a/constants/Colors.ts +++ b/constants/Colors.ts @@ -115,12 +115,10 @@ export const Colors = { 5: '#E74C3C', // Rot - Sehr schwierig }, - // UI-Akzentfarben außerhalb des Zeichnungskontexts (Home-Screen), zentralisiert + // UI-Akzentfarben außerhalb des Zeichnungskontexts, zentralisiert // statt als Hex-Literal an der Verwendungsstelle (Issue #334). accents: { - dailyChallengeBorder: '#F59E0B', - dailyChallengeText: '#D97706', - streakBadge: '#FF6B35', + dailyChallenge: '#F59E0B', }, }; diff --git a/constants/WebAccessibility.ts b/constants/WebAccessibility.ts deleted file mode 100644 index 1d7cd53..0000000 --- a/constants/WebAccessibility.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Web Accessibility Styles - * Focus rings, keyboard navigation, and web-specific accessibility features - * Following WCAG 2.1 AA guidelines - */ - -import { Platform } from 'react-native'; -import Colors from './Colors'; - -/** - * Focus Ring Styles for Web Keyboard Navigation - * WCAG requires 2px minimum, 2px offset for contrast - */ -export const focusRing = Platform.select({ - web: { - outlineWidth: 2, - outlineStyle: 'solid', - outlineColor: Colors.primary, - outlineOffset: 2, - }, - default: {}, -}); - -/** - * Minimum Touch Target Size (Apple HIG & WCAG) - * 44x44px for clickable elements - */ -export const minTouchTarget = { - minHeight: 44, - minWidth: 44, -}; - -/** - * Web-Specific Focus Visible Styles - * Apply to interactive elements for keyboard navigation - */ -export const webFocusVisible = Platform.select({ - web: { - ':focus-visible': { - outlineWidth: 2, - outlineStyle: 'solid', - outlineColor: Colors.primary, - outlineOffset: 2, - }, - }, - default: {}, -}); - -/** - * Global CSS for Web Accessibility - * Insert this into HTML for web builds - */ -export const webAccessibilityCSS = ` -/* WCAG 2.1 AA Focus Rings */ -*:focus-visible { - outline: 2px solid ${Colors.primary}; - outline-offset: 2px; -} - -/* Remove default focus outline (we add our own) */ -*:focus:not(:focus-visible) { - outline: none; -} - -/* Button Focus States */ -button:focus-visible, -a:focus-visible, -[role="button"]:focus-visible, -[role="link"]:focus-visible { - outline: 2px solid ${Colors.primary}; - outline-offset: 2px; -} - -/* Ensure minimum touch targets */ -button, -a, -[role="button"], -[role="link"] { - min-height: 44px; - min-width: 44px; -} - -/* Keyboard Navigation Highlight */ -:focus-visible { - animation: focus-pulse 0.3s ease-in-out; -} - -@keyframes focus-pulse { - 0% { - outline-color: transparent; - } - 100% { - outline-color: ${Colors.primary}; - } -} -`; - -export default { - focusRing, - minTouchTarget, - webFocusVisible, - webAccessibilityCSS, -}; diff --git a/services/DailyChallengeManager.ts b/services/DailyChallengeManager.ts index 0b5a301..832cbaa 100644 --- a/services/DailyChallengeManager.ts +++ b/services/DailyChallengeManager.ts @@ -5,6 +5,7 @@ import { safeStorageOps } from './StorageManager'; import { getTotalLevels } from './LevelManager'; +import { getLocalDateKey } from './StreakManager'; const STORAGE_KEY = '@merke_male:daily_challenge'; @@ -32,10 +33,7 @@ async function saveState(state: DailyChallengeState): Promise { * Gibt den Datums-Key im Format "YYYY-MM-DD" zurück. */ export function getDailyChallengeKey(date: Date = new Date()): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; + return getLocalDateKey(date); } /** diff --git a/services/RatingManager.ts b/services/RatingManager.ts index 798b145..e200f97 100644 --- a/services/RatingManager.ts +++ b/services/RatingManager.ts @@ -4,6 +4,7 @@ */ import { RatingFeedback, StarRating } from '../types'; +import Colors from '../constants/Colors'; /** * Motivierende Aussagen für 1-2 Sterne (werden rotiert) @@ -148,13 +149,6 @@ export function calculateStarsFromSimilarity(similarityScore: number): StarRatin * Gibt die passende Stern-Farbe für die UI zurück */ export function getStarColor(stars: StarRating): string { - // Verwendet Colors.difficulty aus constants/Colors.ts - const colorMap: Record = { - 1: '#E74C3C', // Rot - 2: '#E67E22', // Dunkles Orange - 3: '#F39C12', // Orange - 4: '#2ECC71', // Hellgrün - 5: '#27AE60', // Grün - }; - return colorMap[stars]; + // Umgekehrte Schwierigkeits-Palette: 1 Stern = Rot (difficulty 5), 5 Sterne = Grün (difficulty 1) + return Colors.difficulty[(6 - stars) as keyof typeof Colors.difficulty]; } diff --git a/services/SessionTracker.ts b/services/SessionTracker.ts index 975aa79..a225fa3 100644 --- a/services/SessionTracker.ts +++ b/services/SessionTracker.ts @@ -9,6 +9,7 @@ */ import { createPersistedJson } from './storage/persistedJson'; +import { getLocalDateKey } from './StreakManager'; export const FOUR_WEEKS_MS = 28 * 24 * 60 * 60 * 1000; const MAX_ENTRIES = 1000; // hard cap, defensiv @@ -81,11 +82,7 @@ export async function clearSessions(): Promise { } function localDateKey(iso: string): string { - const d = new Date(iso); - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${y}-${m}-${day}`; + return getLocalDateKey(new Date(iso)); } export function computeStats(records: SessionRecord[]): SessionStats { diff --git a/services/StorageManager.ts b/services/StorageManager.ts index c6a08c8..de9a63c 100644 --- a/services/StorageManager.ts +++ b/services/StorageManager.ts @@ -240,15 +240,17 @@ class StorageManager { try { const current = await this.getSettings(); const updated = { ...current, ...settings }; - await safeStorageOps.setItem(KEYS.THEME, updated.theme); - await safeStorageOps.setItem(KEYS.LANGUAGE, updated.language); - await safeStorageOps.setItem(KEYS.SOUND_ENABLED, JSON.stringify(updated.soundEnabled)); - await safeStorageOps.setItem(KEYS.MUSIC_ENABLED, JSON.stringify(updated.musicEnabled)); - await safeStorageOps.setItem(KEYS.EXTRA_TIME_MODE, JSON.stringify(updated.extraTimeMode)); - await safeStorageOps.setItem( - KEYS.CELEBRATION_ENABLED, - JSON.stringify(updated.celebrationEnabled), - ); + await Promise.all([ + safeStorageOps.setItem(KEYS.THEME, updated.theme), + safeStorageOps.setItem(KEYS.LANGUAGE, updated.language), + safeStorageOps.setItem(KEYS.SOUND_ENABLED, JSON.stringify(updated.soundEnabled)), + safeStorageOps.setItem(KEYS.MUSIC_ENABLED, JSON.stringify(updated.musicEnabled)), + safeStorageOps.setItem(KEYS.EXTRA_TIME_MODE, JSON.stringify(updated.extraTimeMode)), + safeStorageOps.setItem( + KEYS.CELEBRATION_ENABLED, + JSON.stringify(updated.celebrationEnabled), + ), + ]); } catch (error) { console.error('Error saving settings:', error); }