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);
}