From 2571ac0bf931acc0fa5c4f7f22e16cb8d1f0af0b Mon Sep 17 00:00:00 2001 From: S540d Date: Wed, 8 Jul 2026 10:22:26 +0200 Subject: [PATCH 01/86] chore: bump version to 1.7.2 / versionCode 68 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play Store hat bereits 1.7.1 (vc 67, via nie-gemergtem Tag v1.7.1). main stand auf 1.7.0/66 → auf 1.7.2/68 hochgezogen (vc > 67, neuer Name). Co-Authored-By: Claude Opus 4.8 --- app.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app.json b/app.json index 35f3fb7..087bdca 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Merke und Male", "slug": "merke-und-male", - "version": "1.7.0", + "version": "1.7.2", "platforms": ["ios", "android", "web"], "orientation": "portrait", "icon": "./assets/icons/app-icon.png", @@ -37,7 +37,7 @@ }, "package": "com.s540d.merkeundmale", "permissions": [], - "versionCode": 66, + "versionCode": 68, "minSdkVersion": 26, "newArchEnabled": false, "playStoreUrl": "https://play.google.com/store/apps/details?id=com.s540d.merkeundmale", diff --git a/package.json b/package.json index c72ceaa..b8a9f29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "merke-und-male", - "version": "1.7.0", + "version": "1.7.2", "main": "expo-router/entry", "homepage": "https://s540d.github.io/DrawFromMemory/", "repository": { From eb371331f27e9cd6e9ef4eb12666f0448a110bc9 Mon Sep 17 00:00:00 2001 From: S540d Date: Wed, 8 Jul 2026 11:40:01 +0200 Subject: [PATCH 02/86] fix: expo-file-system/expo-sharing auf SDK-55-Versionen anheben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beim SDK-54→55-Upgrade waren beide Module auf SDK-54-Versionen zurückgeblieben (expo-file-system@18, expo-sharing@13) und brachen den Android-Build mit Kotlin-Compile-Fehlern gegen expo-modules-core@55 (Unresolved reference 'AppDirectoriesModuleInterface', 'overrides nothing'). - expo-file-system ~18.0.12 → ~55.0.23 - expo-sharing ~13.0.1 → ~55.0.21 (+ Plugin-Eintrag in app.json) Lokaler Production-Build (vc68) baut damit erfolgreich durch und ist korrekt mit dem Play-Store-Key signiert. Verbleibende Versions-Drifts (RN, expo-router, reanimated u.a.): #276 Co-Authored-By: Claude Opus 4.8 --- app.json | 3 ++- package-lock.json | 51 +++++++++++++++++------------------------------ package.json | 4 ++-- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/app.json b/app.json index 087bdca..05c6c00 100644 --- a/app.json +++ b/app.json @@ -72,7 +72,8 @@ "autoUploadNativeSymbols": false, "url": "https://sentry.io/" } - ] + ], + "expo-sharing" ], "experiments": { "typedRoutes": true diff --git a/package-lock.json b/package-lock.json index 6896ee1..e01ef9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "merke-und-male", - "version": "1.7.0", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "merke-und-male", - "version": "1.7.0", + "version": "1.7.2", "dependencies": { "@expo-google-fonts/nunito": "^0.4.2", "@expo/metro-runtime": "~55.0.6", @@ -16,13 +16,13 @@ "expo": "~55.0.6", "expo-asset": "~55.0.8", "expo-constants": "~55.0.7", - "expo-file-system": "~18.0.12", + "expo-file-system": "~55.0.23", "expo-font": "~55.0.4", "expo-haptics": "~55.0.8", "expo-linear-gradient": "~55.0.8", "expo-localization": "~55.0.8", "expo-router": "~55.0.5", - "expo-sharing": "~13.0.1", + "expo-sharing": "~55.0.21", "expo-status-bar": "~55.0.4", "expo-store-review": "~55.0.1", "lottie-react-native": "~7.3.4", @@ -6824,13 +6824,10 @@ } }, "node_modules/expo-file-system": { - "version": "18.0.12", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.12.tgz", - "integrity": "sha512-HAkrd/mb8r+G3lJ9MzmGeuW2B+BxQR1joKfeCyY4deLl1zoZ48FrAWjgZjHK9aHUVhJ0ehzInu/NQtikKytaeg==", + "version": "55.0.23", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.23.tgz", + "integrity": "sha512-XNV/NaPc7Tvd2JIsWkPW8s2vfJUI/Oe4UMX9TmOGpc6cn5BckDw/nn2Qre9lq4btT4FWHJw4SAOHmATShLC0og==", "license": "MIT", - "dependencies": { - "web-streams-polyfill": "^3.3.2" - }, "peerDependencies": { "expo": "*", "react-native": "*" @@ -7053,12 +7050,19 @@ } }, "node_modules/expo-sharing": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-13.0.1.tgz", - "integrity": "sha512-qych3Nw65wlFcnzE/gRrsdtvmdV0uF4U4qVMZBJYPG90vYyWh2QM9rp1gVu0KWOBc7N8CC2dSVYn4/BXqJy6Xw==", + "version": "55.0.21", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-55.0.21.tgz", + "integrity": "sha512-iiM9nAIIouPRxms41F7ytacna1BzImb5KSI1a9ZlrvrW2ybKx8YDw7+3mClFplMrWp+sy8A+bWkR75mmhKE78w==", "license": "MIT", + "dependencies": { + "@expo/config-plugins": "^55.0.10", + "@expo/config-types": "^55.0.5", + "@expo/plist": "^0.5.4" + }, "peerDependencies": { - "expo": "*" + "expo": "*", + "react": "*", + "react-native": "*" } }, "node_modules/expo-status-bar": { @@ -7306,16 +7310,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "55.0.23", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-55.0.23.tgz", - "integrity": "sha512-XNV/NaPc7Tvd2JIsWkPW8s2vfJUI/Oe4UMX9TmOGpc6cn5BckDw/nn2Qre9lq4btT4FWHJw4SAOHmATShLC0og==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -12853,15 +12847,6 @@ "defaults": "^1.0.3" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "dev": true, diff --git a/package.json b/package.json index b8a9f29..5bb0a13 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,13 @@ "expo": "~55.0.6", "expo-asset": "~55.0.8", "expo-constants": "~55.0.7", - "expo-file-system": "~18.0.12", + "expo-file-system": "~55.0.23", "expo-font": "~55.0.4", "expo-haptics": "~55.0.8", "expo-linear-gradient": "~55.0.8", "expo-localization": "~55.0.8", "expo-router": "~55.0.5", - "expo-sharing": "~13.0.1", + "expo-sharing": "~55.0.21", "expo-status-bar": "~55.0.4", "expo-store-review": "~55.0.1", "lottie-react-native": "~7.3.4", From fb77a2461a521de72123ab57ec2bc002addf1648 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:25:41 +0000 Subject: [PATCH 03/86] docs: Play-Store-Listing-Vorlage (Titel, Kurz- & Langbeschreibung) unter docs/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Texte aus Issue #243 (live seit Juni 2026) als gepflegte Vorlage ins Repo übernommen: Titel, manuell geprüfte Kurzbeschreibungen (7 Sprachen) und Langbeschreibung (EN + DE) inkl. Pflege-Hinweisen. Docs-Index aktualisiert. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Dn1NRntX3V2MASwCQqNwjy --- docs/PLAY_STORE_LISTING.md | 144 +++++++++++++++++++++++++++++++++++++ docs/README.md | 7 +- 2 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 docs/PLAY_STORE_LISTING.md diff --git a/docs/PLAY_STORE_LISTING.md b/docs/PLAY_STORE_LISTING.md new file mode 100644 index 0000000..a0df566 --- /dev/null +++ b/docs/PLAY_STORE_LISTING.md @@ -0,0 +1,144 @@ +# Google Play — Store-Listing + +Vorlage für die Listing-Texte in der Google Play Console. +Quelle: Issue [#243](https://github.com/S540d/DrawFromMemory/issues/243) (Store-Listing-Überarbeitung, live seit Juni 2026). + +**Zeichenlimits (Play Console):** Titel max. 30, Kurzbeschreibung max. 80, Langbeschreibung max. 4000 Zeichen. + +--- + +## 1. Titel (max. 30 Zeichen) + +| Sprache | Titel | Zeichen | +| ------- | -------------------- | ------- | +| DE | Merke und Male | 14 | +| EN | Merke und Male | 14 | + +> Der App-Name bleibt in allen Sprachen identisch (Markenname, entspricht `app.json` / Package `com.s540d.merkeundmale`). Keyword-Ergänzungen im Titel (z. B. „– Memory für Kinder") sind möglich, aber bewusst weggelassen: Der Titel bleibt kurz und einprägsam, Keywords stehen in Kurz- und Langbeschreibung. + +--- + +## 2. Kurzbeschreibung (max. 80 Zeichen, ASO-relevant) + +EN-Original bewusst kurz gehalten, damit Auto-Übersetzungen in den meisten Sprachen unter 80 Zeichen bleiben. „coloring" steht nicht im Titel (Längen-/Keyword-Trade-off), dafür prominent in Zeile 1 der Langbeschreibung. + +### Manuell geprüfte Übersetzungen + +| Sprache | Text | Zeichen | +| ------- | ------------------------------------------------------------ | ------- | +| EN | Memory & drawing game for kids — no ads, offline | 49 | +| DE | Memory- & Zeichenspiel für Kinder — ohne Werbung, offline | 56 | +| ES | Juego de memoria y dibujo para niños — sin anuncios, offline | 60 | +| FR | Jeu de mémoire et dessin pour enfants — sans pub, hors ligne | 60 | +| DA | Hukommelses- og tegnespil for børn — ingen reklamer, offline | 60 | +| FI | Muisti- ja piirtopeli lapsille — ei mainoksia, offline | 54 | +| AR | لعبة ذاكرة ورسم للأطفال — بدون إعلانات، دون إنترنت | 49 | + +> Übrige Sprachen über die Auto-Übersetzung der Play Console; die Tabelle oben dient als manuelle Referenz/Korrektur. + +--- + +## 3. Langbeschreibung (max. 4000 Zeichen) + +### EN (Original) + +``` +Can your child remember the picture — and draw it from memory? A drawing and coloring game that trains memory and concentration through play. No ads. No in-app purchases. Fully offline. Made for kids and toddlers ages 3 and up. + +📚 HOW IT WORKS +1. Look at the picture and memorize it +2. The picture disappears +3. Draw it from memory +4. Compare and learn + +✨ WHY KIDS LOVE IT +• 10 levels that grow with your child +• Simple drawing tools: brush, eraser, fill +• A big, colorful palette +• Unlimited undo & redo +• Works completely offline — perfect for the car, plane or waiting room +• In German and English + +🎯 WHAT YOUR CHILD PRACTICES +• Memory & concentration +• Fine motor skills +• Color recognition +• Creativity and spatial thinking +• And most of all — it's just fun! + +🛡️ MADE FOR PARENTS' PEACE OF MIND +• No ads, ever +• No in-app purchases +• No data collection — everything stays on the device +• No internet needed +• No external links or buttons your child could tap by accident +• COPPA & GDPR compliant + +📖 LEVELS THAT GROW WITH YOUR CHILD +• Levels 1–3: Simple shapes and first colors +• Levels 4–6: Combinations and more details +• Levels 7–10: Bigger challenges for older kids + +💝 Completely free. No hidden costs, no ads, no purchases — just download and play. + +Built with privacy in mind. Source code openly available for those who want to check. +``` + +### DE + +``` +Kann sich dein Kind das Bild merken — und es aus dem Gedächtnis malen? Ein Zeichen- und Malspiel, das Gedächtnis und Konzentration spielerisch trainiert. Keine Werbung. Keine In-App-Käufe. Komplett offline. Für Kinder ab 3 Jahren. + +📚 SO FUNKTIONIERT'S +1. Bild ansehen und merken +2. Das Bild verschwindet +3. Aus dem Gedächtnis nachmalen +4. Vergleichen und lernen + +✨ WARUM KINDER ES LIEBEN +• 10 Level, die mit deinem Kind mitwachsen +• Einfache Werkzeuge: Pinsel, Radierer, Füllen +• Eine große, bunte Farbpalette +• Unbegrenztes Rückgängigmachen +• Funktioniert komplett offline — perfekt für Auto, Flugzeug oder Wartezimmer +• Auf Deutsch und Englisch + +🎯 WAS DEIN KIND TRAINIERT +• Gedächtnis & Konzentration +• Feinmotorik +• Farben erkennen +• Kreativität und räumliches Denken +• Und vor allem: Es macht einfach Spaß! + +🛡️ FÜR DIE RUHE DER ELTERN GEMACHT +• Keine Werbung, niemals +• Keine In-App-Käufe +• Keine Datensammlung — alles bleibt auf dem Gerät +• Kein Internet nötig +• Keine externen Links oder Buttons, die dein Kind versehentlich antippen könnte +• COPPA- & DSGVO-konform + +📖 LEVEL, DIE MITWACHSEN +• Level 1–3: Einfache Formen und erste Farben +• Level 4–6: Kombinationen und mehr Details +• Level 7–10: Größere Herausforderungen für ältere Kinder + +💝 Komplett kostenlos. Keine versteckten Kosten, keine Werbung, keine Käufe — einfach herunterladen und losmalen. + +Mit Datenschutz im Kern entwickelt. Der Quellcode ist offen einsehbar. +``` + +> Übrige Sprachen (ES/FR/IT/NL/PL u. a.) über die Auto-Übersetzung der Play Console erzeugen und stichprobenartig gegen die App-Übersetzungen in `locales/` prüfen. + +--- + +## 4. Pflege-Hinweise + +- **Konsistenz mit der App:** Bei inhaltlichen Änderungen (Level-Anzahl, Sprachen, neue Features wie Themen-Packs oder Spielvarianten) die Beschreibungen nachziehen. Die Langbeschreibung nennt aktuell „10 levels" und „German and English" — bei einem Listing-Update auf 20 Level und 7 Sprachen (de/en/es/fr/it/nl/pl) aktualisieren. +- **Altersangabe konsistent halten:** „ages 3+ / toddlers" ↔ Zielgruppen-/Familienfreigabe in der Console (siehe [PLAY_STORE_DATA_SAFETY.md](PLAY_STORE_DATA_SAFETY.md)). +- **Data Safety:** Antworten für das Data-Safety-Formular stehen in [PLAY_STORE_DATA_SAFETY.md](PLAY_STORE_DATA_SAFETY.md). +- **Visuelle Assets** (Screenshots, Feature-Graphic, Video): siehe Aufwertungs-Plan [#279](https://github.com/S540d/DrawFromMemory/issues/279), Säule 3. + +--- + +_Letzte Aktualisierung: 12. Juli 2026_ diff --git a/docs/README.md b/docs/README.md index 73ab459..7934e14 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,12 @@ Öffentlich zugängliche Dokumentation für „Merke und Male". -> Deployment-Anleitungen, Play-Store-Texte und Asset-Guides werden lokal gepflegt und nicht veröffentlicht. +> Deployment-Anleitungen und Asset-Guides werden lokal gepflegt und nicht veröffentlicht. + +## Play Store + +- **[PLAY_STORE_LISTING.md](PLAY_STORE_LISTING.md)** – Listing-Texte (Titel, Kurz- & Langbeschreibung) +- **[PLAY_STORE_DATA_SAFETY.md](PLAY_STORE_DATA_SAFETY.md)** – Data-Safety-Formular & Families-Checkliste ## Testing From 3b5c294996fd4cd9773dca12f7316ec6a23201a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:18:21 +0000 Subject: [PATCH 04/86] feat: SEO- und Trust-Ausbau der gh-pages-Demo (Issue #279, 3.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reichweite außerhalb des Stores: die Web-Demo bekommt echte SEO-Metadaten (Title, Description mit "Gedächtnistraining Kinder App", Keywords, OG-/ Twitter-Tags, JSON-LD) via expo-router/head, da der Web-Export im "single"-Output-Modus app/+html.tsx ignoriert. Zusätzlich ein Web-only Footer mit Play-Store-Link und Datenschutz-Versprechen auf der Startseite, sowie ein per Env-Flag aktivierbares, privacy-freundliches Analytics-Script (Plausible-kompatibel, no-op ohne EXPO_PUBLIC_PLAUSIBLE_DOMAIN) als Messgrundlage für zukünftige Reichweiten-Entscheidungen. --- CLAUDE.md | 6 ++ app/_layout.tsx | 51 +++++++++++++ app/index.tsx | 3 + components/WebTrustFooter.tsx | 73 +++++++++++++++++++ components/__tests__/HomeScreen.test.tsx | 7 ++ components/__tests__/WebTrustFooter.test.tsx | 64 ++++++++++++++++ locales/de/translations.json | 5 ++ locales/en/translations.json | 5 ++ locales/es/translations.json | 5 ++ locales/fr/translations.json | 5 ++ locales/it/translations.json | 5 ++ locales/nl/translations.json | 5 ++ locales/pl/translations.json | 5 ++ public/feature-graphic.png | Bin 0 -> 56025 bytes services/AnalyticsService.ts | 27 +++++++ 15 files changed, 266 insertions(+) create mode 100644 components/WebTrustFooter.tsx create mode 100644 components/__tests__/WebTrustFooter.test.tsx create mode 100644 public/feature-graphic.png create mode 100644 services/AnalyticsService.ts diff --git a/CLAUDE.md b/CLAUDE.md index 52914a5..4fde76f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ components/ Chip.tsx # UI-Primitiv: Chip Button.tsx # UI-Primitiv: Button (primary = LinearGradient cta, secondary = outlined, ghost = transparent, danger = solid) SkeletonLoader.tsx # Skeleton Placeholder + WebTrustFooter.tsx # Nur Web: Play-Store-Link + Datenschutz-Link am Ende der Startseite (Issue #279, 3.4) services/ FloodFillService.ts # Flood-Fill-Algorithmus (Scanline, 1-Bit-Palette) @@ -72,6 +73,7 @@ services/ RatingManager.ts # Stern-Bewertungen und rotierende Motivations-Nachrichten SoundManager.ts # Web Audio API (Web) + expo-haptics (Native) SentryService.ts # Sentry-Wrapper (init, captureException, etc.) + AnalyticsService.ts # Privacy-freundliches Web-Analytics (Plausible-kompatibel) — no-op ohne EXPO_PUBLIC_PLAUSIBLE_DOMAIN ThemeContext.tsx # ThemeProvider + useTheme Hook (light/dark/system) i18n.ts # Übersetzungs-Service (de/en/es/fr/it/nl/pl) mit listener-basiertem Reload + automatischer Geräte-Spracherkennung ReviewManager.ts # In-App-Review-Trigger: 5-Sterne oder 3. Daily Challenge, 90-Tage-Cooldown — deaktiviert via EXPO_PUBLIC_ENABLE_IN_APP_REVIEW @@ -330,9 +332,13 @@ Alle `EXPO_PUBLIC_*`-Flags sind zur Build-Zeit eingefroren (Expo bündelt sie st | ---------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------- | | `EXPO_PUBLIC_SENTRY_DSN` | — | Sentry-Reporting aktivieren (leer = no-op) | | `EXPO_PUBLIC_ENABLE_IN_APP_REVIEW` | `false` | In-App-Review-Prompt via `expo-store-review` — **deaktiviert bis Play-Store-Listing auditiert** (Issue #219 P0) | +| `EXPO_PUBLIC_PLAUSIBLE_DOMAIN` | — | Privacy-freundliches, cookie-loses Web-Analytics (Plausible-kompatibel) — leer = no-op (Issue #279, 3.4) | +| `EXPO_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | Plausible-Cloud-URL | Überschreibt die Script-Quelle für self-hosted Plausible/Umami-kompatible Instanzen | > **Aktivieren:** In `.env` oder EAS-Build-Profil `EXPO_PUBLIC_ENABLE_IN_APP_REVIEW=true` setzen. > Der Flag wird in `services/ReviewManager.ts` ausgewertet; kein Code-Change nötig. +> +> **Analytics aktivieren:** `EXPO_PUBLIC_PLAUSIBLE_DOMAIN=s540d.github.io` (oder eigene Domain) setzen — Tool-Entscheidung (Plausible-Cloud vs. self-hosted) und Domain-Wahl liegen beim Team, der Flag ist nur die Infrastruktur dafür (`services/AnalyticsService.ts`, aufgerufen in `app/_layout.tsx`). --- diff --git a/app/_layout.tsx b/app/_layout.tsx index df0f177..221b8f2 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -8,6 +8,7 @@ import AnimatedSplashScreen from '@components/AnimatedSplashScreen'; import { useCallback, useEffect, useState, useReducer } from 'react'; import { initLanguage, addLanguageChangeListener } from '@services/i18n'; import { initSentry } from '@services/SentryService'; +import { initAnalytics } from '@services/AnalyticsService'; import { useFonts } from 'expo-font'; import { Nunito_400Regular, @@ -19,6 +20,15 @@ import { // Initialize Sentry as early as possible (no-op on web or without DSN) 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 = + 'Gedächtnistraining für Kinder ab 3 Jahren: Bild merken, aus dem Gedächtnis nachzeichnen, vergleichen. Kostenlos im Browser spielen – offline, ohne Werbung, ohne Datensammlung.'; + /** * Root Layout Content - wrapped with theme context */ @@ -51,6 +61,47 @@ function RootLayoutContent() { + + {/* SEO für die gh-pages-Demo (Issue #279, 3.4) */} + {SEO_TITLE} + + + + + + + + + + + + + + + + + + + + + setShowSettings(false)} /> diff --git a/components/WebTrustFooter.tsx b/components/WebTrustFooter.tsx new file mode 100644 index 0000000..61c11b8 --- /dev/null +++ b/components/WebTrustFooter.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Linking } from 'react-native'; +import { useTranslation } from '@services/i18n'; +import { useTheme } from '@services/ThemeContext'; +import { isWeb } from '../utils/platform'; +import { Spacing, FontSize, FontWeight, FontFamily } from '../constants/Layout'; + +const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.s540d.merkeundmale'; +const PRIVACY_POLICY_URL = 'https://s540d.github.io/DrawFromMemory/PRIVACY_POLICY.html'; + +/** + * Nur auf Web sichtbarer Footer für die gh-pages-Demo: macht den Play-Store-Link + * und das Datenschutz-Versprechen sichtbar, damit die Demo auch verkauft + * statt nur zu zeigen (Issue #279, Punkt 3.4). Die URLs werden hier hart kodiert, + * da `expo-constants` die android-Config-Sektion im Web-Build nicht bündelt. + */ +export default function WebTrustFooter() { + const { t } = useTranslation(); + const { colors } = useTheme(); + + if (!isWeb) return null; + + const openUrl = (url: string) => { + Linking.openURL(url).catch(() => {}); + }; + + return ( + + + {t('footer.tagline')} + + + openUrl(PLAY_STORE_URL)} accessibilityRole="link"> + {t('footer.playStore')} + + · + openUrl(PRIVACY_POLICY_URL)} accessibilityRole="link"> + + {t('footer.privacyPolicy')} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + marginTop: Spacing.sm, + gap: 2, + }, + tagline: { + fontSize: FontSize.xs, + fontWeight: FontWeight.regular, + fontFamily: FontFamily.regular, + textAlign: 'center', + }, + linkRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xs, + }, + link: { + fontSize: FontSize.xs, + fontWeight: FontWeight.semibold, + fontFamily: FontFamily.semibold, + textDecorationLine: 'underline', + }, + separator: { + fontSize: FontSize.xs, + }, +}); diff --git a/components/__tests__/HomeScreen.test.tsx b/components/__tests__/HomeScreen.test.tsx index 735fc3e..a0d3b70 100644 --- a/components/__tests__/HomeScreen.test.tsx +++ b/components/__tests__/HomeScreen.test.tsx @@ -48,6 +48,13 @@ jest.mock('../../components/SettingsModal', () => { }; }); +jest.mock('../../components/WebTrustFooter', () => { + const { View } = require('react-native'); + return function WebTrustFooter() { + return ; + }; +}); + jest.mock('../../components/FloatingStars', () => { const { View } = require('react-native'); return { FloatingStars: () => }; diff --git a/components/__tests__/WebTrustFooter.test.tsx b/components/__tests__/WebTrustFooter.test.tsx new file mode 100644 index 0000000..bdcb812 --- /dev/null +++ b/components/__tests__/WebTrustFooter.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import { Linking, Text, TouchableOpacity } from 'react-native'; + +jest.mock('../../services/i18n', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../services/ThemeContext', () => ({ + useTheme: () => ({ + colors: { + primary: '#6200ee', + text: { primary: '#000', secondary: '#666', light: '#999' }, + }, + }), +})); + +import WebTrustFooter from '../WebTrustFooter'; + +const getAllTexts = (getAllByType: (t: any) => any[]) => + getAllByType(Text) + .map((n: any) => n.props.children) + .flat(); + +describe('WebTrustFooter', () => { + it('renders tagline, Play Store link and privacy policy link', () => { + const { UNSAFE_getAllByType } = render(); + const texts = getAllTexts(UNSAFE_getAllByType); + + expect(texts).toContain('footer.tagline'); + expect(texts).toContain('footer.playStore'); + expect(texts).toContain('footer.privacyPolicy'); + }); + + it('opens the Play Store URL on press', async () => { + const openUrlSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true as any); + const { UNSAFE_getAllByType } = render(); + + const [playStoreLink] = UNSAFE_getAllByType(TouchableOpacity); + await act(async () => { + playStoreLink.props.onPress(); + }); + + expect(openUrlSpy).toHaveBeenCalledWith( + 'https://play.google.com/store/apps/details?id=com.s540d.merkeundmale', + ); + openUrlSpy.mockRestore(); + }); + + it('opens the privacy policy URL on press', async () => { + const openUrlSpy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true as any); + const { UNSAFE_getAllByType } = render(); + + const [, privacyLink] = UNSAFE_getAllByType(TouchableOpacity); + await act(async () => { + privacyLink.props.onPress(); + }); + + expect(openUrlSpy).toHaveBeenCalledWith( + 'https://s540d.github.io/DrawFromMemory/PRIVACY_POLICY.html', + ); + openUrlSpy.mockRestore(); + }); +}); diff --git a/locales/de/translations.json b/locales/de/translations.json index b0a96cb..bd55de0 100644 --- a/locales/de/translations.json +++ b/locales/de/translations.json @@ -21,6 +21,11 @@ "levelOf": "von {{total}}" } }, + "footer": { + "tagline": "Offline · ohne Werbung · keine Datensammlung", + "playStore": "Jetzt im Play Store", + "privacyPolicy": "Datenschutz" + }, "levels": { "title": "Level auswählen", "level": "Level {{number}}", diff --git a/locales/en/translations.json b/locales/en/translations.json index 7c522ec..855d5b1 100644 --- a/locales/en/translations.json +++ b/locales/en/translations.json @@ -21,6 +21,11 @@ "levelOf": "of {{total}}" } }, + "footer": { + "tagline": "Offline · no ads · no data collection", + "playStore": "Get it on Google Play", + "privacyPolicy": "Privacy Policy" + }, "levels": { "title": "Select Level", "level": "Level {{number}}", diff --git a/locales/es/translations.json b/locales/es/translations.json index 82c0807..68c8aac 100644 --- a/locales/es/translations.json +++ b/locales/es/translations.json @@ -21,6 +21,11 @@ "levelOf": "de {{total}}" } }, + "footer": { + "tagline": "Sin conexión · sin anuncios · sin recopilación de datos", + "playStore": "Disponible en Google Play", + "privacyPolicy": "Política de privacidad" + }, "levels": { "title": "Elegir nivel", "level": "Nivel {{number}}", diff --git a/locales/fr/translations.json b/locales/fr/translations.json index 2e659d8..6547e66 100644 --- a/locales/fr/translations.json +++ b/locales/fr/translations.json @@ -21,6 +21,11 @@ "levelOf": "sur {{total}}" } }, + "footer": { + "tagline": "Hors ligne · sans pub · sans collecte de données", + "playStore": "Disponible sur Google Play", + "privacyPolicy": "Politique de confidentialité" + }, "levels": { "title": "Choisir un niveau", "level": "Niveau {{number}}", diff --git a/locales/it/translations.json b/locales/it/translations.json index 643e2d4..e716e8a 100644 --- a/locales/it/translations.json +++ b/locales/it/translations.json @@ -21,6 +21,11 @@ "levelOf": "di {{total}}" } }, + "footer": { + "tagline": "Offline · senza pubblicità · nessuna raccolta dati", + "playStore": "Disponibile su Google Play", + "privacyPolicy": "Informativa sulla privacy" + }, "levels": { "title": "Scegli livello", "level": "Livello {{number}}", diff --git a/locales/nl/translations.json b/locales/nl/translations.json index 761d1c3..055fbdc 100644 --- a/locales/nl/translations.json +++ b/locales/nl/translations.json @@ -21,6 +21,11 @@ "levelOf": "van {{total}}" } }, + "footer": { + "tagline": "Offline · geen advertenties · geen gegevensverzameling", + "playStore": "Beschikbaar op Google Play", + "privacyPolicy": "Privacybeleid" + }, "levels": { "title": "Level kiezen", "level": "Level {{number}}", diff --git a/locales/pl/translations.json b/locales/pl/translations.json index 645c871..7028c45 100644 --- a/locales/pl/translations.json +++ b/locales/pl/translations.json @@ -21,6 +21,11 @@ "levelOf": "z {{total}}" } }, + "footer": { + "tagline": "Offline · bez reklam · bez zbierania danych", + "playStore": "Dostępne w Google Play", + "privacyPolicy": "Polityka prywatności" + }, "levels": { "title": "Wybierz poziom", "level": "Poziom {{number}}", diff --git a/public/feature-graphic.png b/public/feature-graphic.png new file mode 100644 index 0000000000000000000000000000000000000000..53bb0774c281b7ba9ed2daba89224a0c5f4aa102 GIT binary patch literal 56025 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%0DILU9ML}dH5Z}go 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 KlBf_f_WwV+WrE!R literal 0 HcmV?d00001 diff --git a/services/AnalyticsService.ts b/services/AnalyticsService.ts new file mode 100644 index 0000000..41009be --- /dev/null +++ b/services/AnalyticsService.ts @@ -0,0 +1,27 @@ +import { isWeb } from '../utils/platform'; + +// Zur Build-Zeit eingefroren (EXPO_PUBLIC_*), analog zu EXPO_PUBLIC_SENTRY_DSN. +// Leer = kein Script, kein Tracking (no-op). Erwartet die Domain eines +// Plausible-kompatiblen Endpunkts (self-hosted oder plausible.io), z. B. "s540d.github.io". +const PLAUSIBLE_DOMAIN = process.env.EXPO_PUBLIC_PLAUSIBLE_DOMAIN ?? ''; +const PLAUSIBLE_SCRIPT_SRC = + process.env.EXPO_PUBLIC_PLAUSIBLE_SCRIPT_SRC ?? 'https://plausible.io/js/script.js'; + +const SCRIPT_ID = 'plausible-analytics-script'; + +/** + * Fügt ein privacy-freundliches, cookie-loses Analytics-Script (Plausible-kompatibel) + * in den Web-Head ein. No-op auf Native und wenn EXPO_PUBLIC_PLAUSIBLE_DOMAIN nicht + * gesetzt ist (Issue #279, 3.4 – Messgrundlage für Reichweite außerhalb des Stores). + */ +export function initAnalytics() { + if (!isWeb || !PLAUSIBLE_DOMAIN) return; + if (typeof document === 'undefined' || document.getElementById(SCRIPT_ID)) return; // platform-safe + + const script = document.createElement('script'); // platform-safe + script.id = SCRIPT_ID; + script.defer = true; + script.dataset.domain = PLAUSIBLE_DOMAIN; + script.src = PLAUSIBLE_SCRIPT_SRC; + document.head.appendChild(script); // platform-safe +} From 402feaf5f3cb2d74b0506e9493420f372e13cb63 Mon Sep 17 00:00:00 2001 From: DevSven <184159789+S540d@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:33:06 +0200 Subject: [PATCH 05/86] =?UTF-8?q?Android=20Large-Screen-Kompatibilit=C3=A4?= =?UTF-8?q?t=20f=C3=BCr=20Play=20Store=20(Issue=20#278)=20(#281)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Android large-screen compatibility for Play Store (Issue #278) Set orientation to "default" instead of forcing portrait, and add a config plugin that patches the generated AndroidManifest.xml with android:resizeableActivity="true" on MainActivity, since Expo has no built-in app.json field for it. Verified via `expo config --type introspect` that the generated manifest carries both changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ErESLx67f6bi4CYk5yUUgs * style: Prettier-Formatierung nach Merge von testing --------- Co-authored-by: Claude Co-authored-by: S540d --- CLAUDE.md | 12 ++++++------ app.json | 3 ++- components/WebTrustFooter.tsx | 8 ++------ docs/PLAY_STORE_LISTING.md | 8 ++++---- plugins/withAndroidResizeableActivity.js | 21 +++++++++++++++++++++ 5 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 plugins/withAndroidResizeableActivity.js diff --git a/CLAUDE.md b/CLAUDE.md index 4fde76f..0088a98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -328,12 +328,12 @@ Colors.glass.darkShadow; // { boxShadow, elevation } — dunkel Alle `EXPO_PUBLIC_*`-Flags sind zur Build-Zeit eingefroren (Expo bündelt sie statisch). -| Env-Var | Default | Bedeutung | -| ---------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------- | -| `EXPO_PUBLIC_SENTRY_DSN` | — | Sentry-Reporting aktivieren (leer = no-op) | -| `EXPO_PUBLIC_ENABLE_IN_APP_REVIEW` | `false` | In-App-Review-Prompt via `expo-store-review` — **deaktiviert bis Play-Store-Listing auditiert** (Issue #219 P0) | -| `EXPO_PUBLIC_PLAUSIBLE_DOMAIN` | — | Privacy-freundliches, cookie-loses Web-Analytics (Plausible-kompatibel) — leer = no-op (Issue #279, 3.4) | -| `EXPO_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | Plausible-Cloud-URL | Überschreibt die Script-Quelle für self-hosted Plausible/Umami-kompatible Instanzen | +| Env-Var | Default | Bedeutung | +| ---------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------- | +| `EXPO_PUBLIC_SENTRY_DSN` | — | Sentry-Reporting aktivieren (leer = no-op) | +| `EXPO_PUBLIC_ENABLE_IN_APP_REVIEW` | `false` | In-App-Review-Prompt via `expo-store-review` — **deaktiviert bis Play-Store-Listing auditiert** (Issue #219 P0) | +| `EXPO_PUBLIC_PLAUSIBLE_DOMAIN` | — | Privacy-freundliches, cookie-loses Web-Analytics (Plausible-kompatibel) — leer = no-op (Issue #279, 3.4) | +| `EXPO_PUBLIC_PLAUSIBLE_SCRIPT_SRC` | Plausible-Cloud-URL | Überschreibt die Script-Quelle für self-hosted Plausible/Umami-kompatible Instanzen | > **Aktivieren:** In `.env` oder EAS-Build-Profil `EXPO_PUBLIC_ENABLE_IN_APP_REVIEW=true` setzen. > Der Flag wird in `services/ReviewManager.ts` ausgewertet; kein Code-Change nötig. diff --git a/app.json b/app.json index 05c6c00..06a4549 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "merke-und-male", "version": "1.7.2", "platforms": ["ios", "android", "web"], - "orientation": "portrait", + "orientation": "default", "icon": "./assets/icons/app-icon.png", "userInterfaceStyle": "light", "scheme": "merkeundmale", @@ -73,6 +73,7 @@ "url": "https://sentry.io/" } ], + "./plugins/withAndroidResizeableActivity.js", "expo-sharing" ], "experiments": { diff --git a/components/WebTrustFooter.tsx b/components/WebTrustFooter.tsx index 61c11b8..adff510 100644 --- a/components/WebTrustFooter.tsx +++ b/components/WebTrustFooter.tsx @@ -26,18 +26,14 @@ export default function WebTrustFooter() { return ( - - {t('footer.tagline')} - + {t('footer.tagline')} openUrl(PLAY_STORE_URL)} accessibilityRole="link"> {t('footer.playStore')} · openUrl(PRIVACY_POLICY_URL)} accessibilityRole="link"> - - {t('footer.privacyPolicy')} - + {t('footer.privacyPolicy')} diff --git a/docs/PLAY_STORE_LISTING.md b/docs/PLAY_STORE_LISTING.md index a0df566..951a616 100644 --- a/docs/PLAY_STORE_LISTING.md +++ b/docs/PLAY_STORE_LISTING.md @@ -9,10 +9,10 @@ Quelle: Issue [#243](https://github.com/S540d/DrawFromMemory/issues/243) (Store- ## 1. Titel (max. 30 Zeichen) -| Sprache | Titel | Zeichen | -| ------- | -------------------- | ------- | -| DE | Merke und Male | 14 | -| EN | Merke und Male | 14 | +| Sprache | Titel | Zeichen | +| ------- | -------------- | ------- | +| DE | Merke und Male | 14 | +| EN | Merke und Male | 14 | > Der App-Name bleibt in allen Sprachen identisch (Markenname, entspricht `app.json` / Package `com.s540d.merkeundmale`). Keyword-Ergänzungen im Titel (z. B. „– Memory für Kinder") sind möglich, aber bewusst weggelassen: Der Titel bleibt kurz und einprägsam, Keywords stehen in Kurz- und Langbeschreibung. diff --git a/plugins/withAndroidResizeableActivity.js b/plugins/withAndroidResizeableActivity.js new file mode 100644 index 0000000..a18f422 --- /dev/null +++ b/plugins/withAndroidResizeableActivity.js @@ -0,0 +1,21 @@ +const { withAndroidManifest } = require('expo/config-plugins'); + +// Play Store large-screen policy (Android 16): flags apps whose main activity +// isn't resizeable. Expo has no app.json field for this, so patch the +// generated AndroidManifest.xml directly (see Issue #278). +const withAndroidResizeableActivity = config => { + return withAndroidManifest(config, config => { + const mainApplication = config.modResults.manifest.application?.[0]; + const mainActivity = mainApplication?.activity?.find( + activity => activity.$['android:name'] === '.MainActivity', + ); + + if (mainActivity) { + mainActivity.$['android:resizeableActivity'] = 'true'; + } + + return config; + }); +}; + +module.exports = withAndroidResizeableActivity; From a43afcc78ed9dfa101d77e6713810d4fbf537ae8 Mon Sep 17 00:00:00 2001 From: DevSven <184159789+S540d@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:34:33 +0200 Subject: [PATCH 06/86] chore: project-templates sync (GLOBAL POLICY + commands) (#283) --- ".claude/commands/aufr\303\244umen.md" | 30 ++++++++++++ .claude/commands/cache-clean.md | 44 +++++++++++++++++ .claude/commands/pr-review.md | 31 +++++++----- .claude/commands/security-review.md | 65 ++++++++++++++++++++++++++ CLAUDE.md | 30 ++++++++++++ 5 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 .claude/commands/cache-clean.md create mode 100644 .claude/commands/security-review.md diff --git "a/.claude/commands/aufr\303\244umen.md" "b/.claude/commands/aufr\303\244umen.md" index f95ccb0..fd2bb4f 100644 --- "a/.claude/commands/aufr\303\244umen.md" +++ "b/.claude/commands/aufr\303\244umen.md" @@ -2,6 +2,36 @@ Führe den täglichen Cleanup-Workflow durch: +## 0. Branch-Übersicht: testing vs. main (alle Repos) + +Erstelle zu Beginn eine Tabelle aller Repositories mit dem Stand von `testing` gegenüber `main`: + +```bash +for repo in Eisenhauer 1x1_Trainer DrawFromMemory EnergyPriceGermany Pflanzkalender safe_my_plants CD-to-Spotify-PWA project-templates; do + dir="/Users/svenstrohkark/Documents/Programmierung/Projects/$repo" + if [ -d "$dir/.git" ]; then + cd "$dir" + git fetch --all --prune -q 2>/dev/null + ab=$(git rev-list --left-right --count origin/main...origin/testing 2>/dev/null) + main_ahead=$(echo $ab | awk '{print $1}') + test_ahead=$(echo $ab | awk '{print $2}') + echo "$repo | testing +$test_ahead | main +$main_ahead" + fi +done +``` + +Zeige das Ergebnis als Markdown-Tabelle: + +| Projekt | testing ahead | main ahead | Status | +| ------- | ------------- | ---------- | -------------------------------------- | +| ... | ... | ... | ✅ OK / ⚠️ Divergiert / 🔴 main voraus | + +**Statusregeln:** + +- ✅ OK — main_ahead = 0 (testing enthält main vollständig) +- ⚠️ Leicht divergiert — main_ahead ≤ 3 und nur Auto-Commits (z.B. marketdata) +- 🔴 main voraus — main_ahead > 0 mit echten Commits → Sync-PR nötig + ## 1. Repository Status prüfen - Prüfe `git status` für uncommitted changes diff --git a/.claude/commands/cache-clean.md b/.claude/commands/cache-clean.md new file mode 100644 index 0000000..517a371 --- /dev/null +++ b/.claude/commands/cache-clean.md @@ -0,0 +1,44 @@ +# Lokaler Cache-Cleanup + +Führe den lokalen Cache-Cleanup für alle Projekte aus (Festplattenplatz freigeben). + +Dies ist ein manueller, seltener Eingriff (kein automatischer Cron) — nur auf +explizite Anfrage ausführen. + +## 1. Report erzeugen (Dry-Run) + +Führe im project-templates-Verzeichnis aus: + +```bash +cd "$(find ~/Documents -maxdepth 4 -type d -name project-templates 2>/dev/null | head -1)" +./scripts/clean-local-cache.sh --dry-run +``` + +Zeige das Ergebnis (Größen pro Projekt, geplante Löschungen) als Übersicht. + +## 2. Rückfrage + +Frage explizit: "Sollen diese Caches gelöscht werden? (node_modules, dist, +build, .expo, android/.gradle, android/app/build)" + +Bei Zustimmung: + +```bash +./scripts/clean-local-cache.sh --yes +``` + +## 3. Optional: globale Caches + +Frage separat: "Auch globale Caches leeren? (~/.gradle/caches ~9GB, ~/.npm +~900MB — betrifft alle Gradle-/Node-Projekte gemeinsam)" + +Bei Zustimmung: + +```bash +./scripts/clean-local-cache.sh --yes --global +``` + +## 4. Hinweis nach dem Löschen + +Erinnere daran, dass beim nächsten Start des jeweiligen Projekts +`npm install` (und ggf. `npx expo prebuild` / Gradle-Sync) nötig ist. diff --git a/.claude/commands/pr-review.md b/.claude/commands/pr-review.md index bbf77b0..f8e3bdd 100644 --- a/.claude/commands/pr-review.md +++ b/.claude/commands/pr-review.md @@ -59,21 +59,28 @@ Einen Pull Request gründlich prüfen, Code-Review-Suggestions umsetzen und für - Falls neue Features/Breaking Changes: Schlage CHANGELOG-Eintrag vor - Prüfe ob README/Docs aktualisiert werden müssen -### 6. Merge-Gate (review-gate) – automatischer Claude-Review + Autofix +### 6. Merge-Gate (review-gate) + KI-Review -Der Workflow `reusable-pr-review.yml` korrigiert den PR automatisch und reviewt -den End-Stand, dann setzt er den required Status-Check **`review-gate`** und ein Label: +**Kostenloses Gate (automatisch, keine API-Kosten):** Der Workflow +`reusable-mergeability.yml` läuft bei jedem PR, postet einen Sticky-Kommentar +(Zielbranch, Konflikt-Status, CI-Übersicht, Checkliste) und setzt den required +Status-Check **`review-gate`**: -1. **Autofix:** Ein Claude-Agent fixt alle umsetzbaren Findings selbst (Commit `[auto]` + Push). -2. **Review:** bewertet den korrigierten Stand: - - **Keine offenen Findings → grün** + Label `ready to merge` → Merge frei. - - **Findings übrig → rot** + Inline-Kommentare + Label `needs human review`. +- **kein Merge-Konflikt → grün** → Merge möglich (sofern auch CI grün ist). +- **Merge-Konflikt → rot** → Branch aktualisieren. -Bei `needs human review`: Findings lesen, klären/umsetzen, pushen → frischer Lauf. +**Tiefer KI-Review (primärer Weg, abo-basiert, ohne Pay-per-Token):** Führe das +Slash-Command `/review` (bzw. `/code-review --comment`) aus Claude Code aus — +lokal am PC **oder** in einer Web-Session vom Telefon. Das ist durch dein +Pro/Max-Abo gedeckt und kostet keine metered API-Token. Mit `--comment` werden die +Findings als PR-Kommentare gepostet, mit `--fix` direkt im Working-Tree umgesetzt. -> ⚠️ Es gibt **kein** manuelles Quittierungs-Label mehr. Den roten Gate öffnet nur -> ein sauberer Review (alle Findings gelöst). Solange `needs human review` gesetzt -> ist: **nicht mergen**, bis die offenen Punkte geklärt sind. +**Optionaler API-Fallback (kostet metered API):** Setze das Label **`ai-review`** +am PR → `pr-review.yml` postet einen beratenden Claude-Review (Haiku). Dieser +blockiert den Merge **nicht** — er ergänzt nur das kostenlose Gate. + +> ℹ️ Der KI-Review ist beratend; verbindlich für den Merge sind die kostenlosen, +> deterministischen Checks (CI grün + `review-gate` grün + keine Konflikte). ### 7. Merge vorbereiten @@ -145,7 +152,7 @@ Bei `needs human review`: Findings lesen, klären/umsetzen, pushen → frischer - ❌ CI/CD Tests fehlgeschlagen - ❌ Merge Conflicts vorhanden -- ❌ `review-gate` ist rot bzw. Label `needs human review` gesetzt (offene Findings erst klären) +- ❌ `review-gate` ist rot (Merge-Konflikt – erst Branch aktualisieren) - ❌ Target-Branch ist `main` oder `production` (extra Vorsicht, nur mit Freigabe + `--admin`) **Immer fragen vor:** diff --git a/.claude/commands/security-review.md b/.claude/commands/security-review.md new file mode 100644 index 0000000..365e91f --- /dev/null +++ b/.claude/commands/security-review.md @@ -0,0 +1,65 @@ +# Security Review (Cross-Project) + +Globaler Security-Scan über alle lokalen Projekte mit konsolidiertem Report (Issue #7). + +> **Ablageort:** global unter `~/.claude/commands/security-review.md`, nicht pro Projekt. +> Begründung: scannt projektübergreifend, gehört nicht in ein einzelnes Repo. + +## Ziel + +Alle lokalen Projekt-Repositories auf Secrets, Fingerprints, getrackte sensible Dateien +und Gitignore-Lücken prüfen – ein zusammengefasster Bericht statt sieben Einzelläufe. + +## Workflow + +### 1. Projekte ermitteln + +- Bestimme die zu prüfenden Repos (Standard: alle App-Projekte im Workspace). +- Pro Repo: ist es ein Git-Repo? Aktueller Branch? Uncommitted Changes? + +### 2. Pro Projekt scannen + +Für jedes Repo dieselben Checks (entsprechen `reusable-security-scan.yml`): + +- **Signing-Fingerprints** (SHA1/SHA256 Colon-Hex): + ```bash + git -C "$repo" grep -nE '([0-9A-Fa-f]{2}:){15,}[0-9A-Fa-f]{2}' -- . ':!*.lock' + ``` +- **Hardcodierte API-Keys / Tokens:** + ```bash + git -C "$repo" grep -niE '(api[_-]?key|secret[_-]?key|auth[_-]?token|access[_-]?token)["'\'' ]*[:=]["'\'' ]*[A-Za-z0-9_-]{16,}' -- . ':!*.lock' + ``` +- **Getrackte sensible Dateien:** (nur konventionell-geheime .env – nicht committete `.env.`-Config) + ```bash + git -C "$repo" ls-files | grep -E '(credentials\.json$|\.jks$|\.keystore$|\.p12$|\.p8$|(^|/)\.env(\.local)?$|(^|/)\.env\.[^/]*\.local$)' + ``` +- **Gitignore-Pflichteinträge** vorhanden? (`.env`, `.env.local`, `credentials.json`, `*.jks`, `*.keystore`, `*.p12`, `*.p8`, `docs/private/`) – deckungsgleich mit `reusable-gitignore-audit.yml`. `.env.` mit reiner Public-Config ist erlaubt. +- **`docs/private/` nicht getrackt?** +- **`npm audit`** (falls `package.json` vorhanden) – Vulnerabilities zählen. + +### 3. Befunde einordnen + +- Bekannte False Positives kennzeichnen (z.B. CrUX-Demo-Key in `@react-native/debugger-frontend`). +- Severity zuordnen: 🔴 Secret/Fingerprint getrackt · 🟡 Gitignore-Lücke · 🟢 nur Hinweis. + +### 4. Konsolidierter Report + +Tabelle über alle Projekte: + +| Projekt | Fingerprints | Secrets | Sensible Dateien | Gitignore | npm audit | +| ------- | ------------ | ------- | ---------------- | --------- | --------- | +| ... | ✅/❌ | ✅/❌ | ✅/❌ | ✅/⚠️ | 0 / N | + +- Anschließend priorisierte Handlungsempfehlungen (kritischste zuerst). +- Nichts automatisch ändern – nur berichten und Fixes vorschlagen. + +## Sicherheitschecks + +- **Niemals** gefundene Secrets im Klartext in Commits/Issues/Logs ausgeben – nur Datei + Zeile. +- Bei echtem Leak: sofort als kritisch markieren, Rotation + History-Bereinigung empfehlen. + +## Best Practices + +✅ Regelmäßig laufen lassen (z.B. wöchentlich, ergänzend zu `weekly-audit.yml`). +✅ Public-Repos strenger behandeln (alle Workspace-Projekte sind öffentlich). +❌ Keine destruktiven Aktionen ohne Rückfrage (kein `git filter-repo` etc. automatisch). diff --git a/CLAUDE.md b/CLAUDE.md index 0088a98..7ddf4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -438,3 +438,33 @@ Stand: `main` @ v1.7.0 / versionCode 66. `testing` liegt voraus: enthält zusät - **Nexus 6**: EOL — `minSdkVersion` = 26; Nexus 6 endet bei API 25 (geschlossen via Issue #172) - **iOS**: Nicht primär getestet (Fokus auf Web + Android) - **iOS App Store**: Bundle ID `com.s540d.merkeundmale`, App Store URL noch TBD + + + +## [GLOBAL POLICY] + +> Automatisch synchronisiert aus project-templates (Issue #7). Nicht manuell editieren – +> Änderungen hier werden beim nächsten Sync überschrieben. Quelle anpassen statt lokal. + +- PRs immer gegen `testing`, nie direkt gegen `staging` oder `main` +- Merge auf `main` nur mit expliziter schriftlicher Freigabe +- `--delete-branch` nur für Feature-Branches (nie staging/testing) +- **Lokales Branch-Cleanup:** `main` und `testing` NIE löschen — auch nicht beim Bulk-Delete verwaister `[gone]`-Branches. Ein fehlender `origin/main`/`origin/testing` ist ein **wiederherzustellender Defekt** (lokal behalten, nach origin zurückpushen), kein Aufräum-Signal. +- `--no-verify` nur auf explizite Bitte +- **Vor jedem Push: lokale Tests ausführen** (`npm test` bzw. projektspezifischer Test-Befehl) – kein Push ohne grüne lokale Tests +- **Kein Merge bei CI-Fail** – Branch Protection erzwingt das technisch; nie mit `--admin` umgehen außer auf explizite Bitte + +## [ANDROID BUILD – PFLICHTREGELN] + +- **Git-Tag** nach jedem Play-Store-Upload setzen: `git tag vX.Y.Z && git push origin vX.Y.Z` – der Tag markiert den tatsächlich veröffentlichten Stand und dient als Changelog-Baseline für den nächsten Build +- **EAS Local Build (DrawFromMemory):** Workingdir vor jedem Build leeren: `rm -rf ~/tmp/eas-build && mkdir -p ~/tmp/eas-build` – ein nicht-leeres Verzeichnis bricht den Build sofort ab +- **Disk-Check vor EAS Build:** Skia-Libraries benötigen ~5–8 GB. Bei < 5 GB frei: `npm cache clean --force && rm -rf ~/.npm/_npx` (~13 GB, sicher löschbar) +- **JAVA_HOME** für EAS/Expo-Builds explizit auf Android Studio JBR setzen: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"` +- **Gradle-Lock nach Absturz:** Bei "Cannot lock file hash cache"-Fehler Daemons stoppen: `pkill -f GradleDaemon`, dann Workingdir leeren und neu starten +- **AAB-Archiv:** Gebaute Release-AABs in einem **gitignored** `aab-archive/`-Verzeichnis im Repo-Root ablegen (in `.gitignore` aufnehmen – AABs sind 3–110 MB und gehören nie in die Git-History). Benennung: `-vX.Y.Z-vc-YYYY-MM-DD.aab`. **Retention: max. 2 Dateien** (aktuelles Release + ein Vorgänger für schnelles Rollback); ältere AABs löschen. Der Git-Tag `vX.Y.Z` ist die eigentliche Release-Baseline – ältere AABs lassen sich daraus jederzeit neu bauen. + +## [CI – CACHE-CLEANUP] + +- **Cache-Cleanup-Workflow** (`.github/workflows/cache-cleanup.yml`) in jedem Repo mit GitHub-Actions-Caches: löscht wöchentlich (So 03:00 UTC) bzw. on-demand alle Action-Caches älter als der jeweils letzte Lauf. GitHub-Limit ist 10 GB pro Repo – ohne Cleanup laufen Build-Caches (node_modules, Gradle, Expo) voll und verdrängen frische Einträge. Vorlage: `cache-cleanup.yml` in project-templates. + + From 943b291b2f5eeb1794e44bd5aef55a8bbe186ad3 Mon Sep 17 00:00:00 2001 From: DevSven <184159789+S540d@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:01:35 +0200 Subject: [PATCH 07/86] feat: Mascot + einheitliches Fortschrittssystem, Altersstufen-Auswahl (Issue #279, 1.1 + 1.3) (#284) --- app/game.tsx | 13 ++ app/index.tsx | 65 ++++++- app/levels.tsx | 25 +++ components/AgeGroupModal.tsx | 152 ++++++++++++++++ components/Mascot.tsx | 193 +++++++++++++++++++++ components/MascotUnlockToast.tsx | 126 ++++++++++++++ components/SettingsModal.tsx | 21 +++ components/__tests__/HomeScreen.test.tsx | 25 +++ components/game/ResultPhase.tsx | 26 ++- locales/de/translations.json | 29 ++++ locales/en/translations.json | 29 ++++ locales/es/translations.json | 29 ++++ locales/fr/translations.json | 29 ++++ locales/it/translations.json | 29 ++++ locales/nl/translations.json | 29 ++++ locales/pl/translations.json | 29 ++++ services/AgeGroupManager.ts | 92 ++++++++++ services/MascotManager.ts | 92 ++++++++++ services/__tests__/AgeGroupManager.test.ts | 56 ++++++ services/__tests__/MascotManager.test.ts | 74 ++++++++ services/__tests__/useGamePhase.test.ts | 11 ++ services/useGamePhase.ts | 16 +- types/index.ts | 8 + 23 files changed, 1185 insertions(+), 13 deletions(-) create mode 100644 components/AgeGroupModal.tsx create mode 100644 components/Mascot.tsx create mode 100644 components/MascotUnlockToast.tsx create mode 100644 services/AgeGroupManager.ts create mode 100644 services/MascotManager.ts create mode 100644 services/__tests__/AgeGroupManager.test.ts create mode 100644 services/__tests__/MascotManager.test.ts diff --git a/app/game.tsx b/app/game.tsx index 7f651b8..d4411d2 100644 --- a/app/game.tsx +++ b/app/game.tsx @@ -15,6 +15,7 @@ import SettingsModal from '@components/SettingsModal'; import { ErrorBoundary } from '@components/ErrorBoundary'; import ConfettiBurst from '@components/ConfettiBurst'; import BadgeUnlockToast from '@components/BadgeUnlockToast'; +import MascotUnlockToast from '@components/MascotUnlockToast'; import TutorialOverlay from '@components/TutorialOverlay'; import { markOnboardingDone } from '@services/OnboardingManager'; import SoundManager from '@services/SoundManager'; @@ -22,6 +23,7 @@ import { useGamePhase } from '@services/useGamePhase'; import { checkAndUnlock, type AchievementDef } from '@services/AchievementManager'; import { getStreakData } from '@services/StreakManager'; import storageManager from '@services/StorageManager'; +import { getAgeGroup, getDefaultStrokeWidthForAgeGroup } from '@services/AgeGroupManager'; import MemorizePhase from '@components/game/MemorizePhase'; import DrawPhase from '@components/game/DrawPhase'; import ResultPhase from '@components/game/ResultPhase'; @@ -87,6 +89,8 @@ export default function GameScreen() { isReplaying, setIsReplaying, replayPaths, + newMascotUnlock, + clearMascotUnlock, handleRatingSubmit, saveToGallery, startReplay, @@ -142,6 +146,14 @@ export default function GameScreen() { init(); }, []); + // Altersgerechte Standard-Strichstärke beim Rundenstart (Issue #279, 1.3) + useEffect(() => { + getAgeGroup().then(ageGroup => { + drawing.setStrokeWidth(getDefaultStrokeWidthForAgeGroup(ageGroup)); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Reset tutorial hint when phase changes so each phase shows its own hint useEffect(() => { if (isTutorial) setTutorialHintVisible(true); @@ -410,6 +422,7 @@ export default function GameScreen() { )} + {/* Tutorial Coach Mark */} {isTutorial && diff --git a/app/index.tsx b/app/index.tsx index 4bf05a4..c4425d2 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -19,6 +19,11 @@ import { FloatingStars } from '@components/FloatingStars'; import OnboardingModal from '@components/OnboardingModal'; import { isOnboardingDone } from '@services/OnboardingManager'; import WebTrustFooter from '@components/WebTrustFooter'; +import Mascot from '@components/Mascot'; +import AgeGroupModal from '@components/AgeGroupModal'; +import { isAgeGroupSelected, setAgeGroup } from '@services/AgeGroupManager'; +import { getMascotProgress, getHomeGreetingKey, type MascotUnlock } from '@services/MascotManager'; +import type { AgeGroup } from '../types'; export default function HomeScreen() { const { t } = useTranslation(); @@ -27,13 +32,31 @@ export default function HomeScreen() { const insets = useSafeAreaInsets(); const [showSettings, setShowSettings] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); - const [dailyCompleted, setDailyCompleted] = useState(false); + const [showAgeGroup, setShowAgeGroup] = useState(false); + const [mascotNextUnlock, setMascotNextUnlock] = useState(null); + + const maybeShowOnboarding = useCallback(async () => { + const done = await isOnboardingDone(); + if (!done) setShowOnboarding(true); + }, []); useEffect(() => { - isOnboardingDone().then(done => { - if (!done) setShowOnboarding(true); + isAgeGroupSelected().then(async selected => { + if (!selected) { + setShowAgeGroup(true); + return; + } + await maybeShowOnboarding(); }); - }, []); + }, [maybeShowOnboarding]); + + const handleAgeGroupConfirm = async (ageGroup: AgeGroup) => { + await setAgeGroup(ageGroup); + setShowAgeGroup(false); + await maybeShowOnboarding(); + }; + + const [dailyCompleted, setDailyCompleted] = useState(false); const [secondsLeft, setSecondsLeft] = useState(() => getSecondsUntilMidnight()); const [dailyLevel, setDailyLevel] = useState(() => getDailyChallengeLevel()); const [currentStreak, setCurrentStreak] = useState(0); @@ -46,9 +69,14 @@ export default function HomeScreen() { const refresh = async () => { setSecondsLeft(getSecondsUntilMidnight()); setDailyLevel(getDailyChallengeLevel()); - const [completed, streakData] = await Promise.all([isTodayCompleted(), getStreakData()]); + const [completed, streakData, mascotProgress] = await Promise.all([ + isTodayCompleted(), + getStreakData(), + getMascotProgress(), + ]); setDailyCompleted(completed); setCurrentStreak(streakData.currentStreak); + setMascotNextUnlock(mascotProgress.nextUnlock); }; refresh(); @@ -99,6 +127,24 @@ export default function HomeScreen() { + {/* Mascot-Begrüßung — einheitlicher Fortschritts-Companion (Issue #279, 1.1) */} + + = 2 ? 'happy' : 'neutral'} + message={t(getHomeGreetingKey(currentStreak), { count: String(currentStreak) })} + testID="home-mascot" + /> + {mascotNextUnlock && ( + + {t('mascot.nextUnlock', { + count: String(mascotNextUnlock.starsRequired), + item: t(mascotNextUnlock.labelKey), + })} + + )} + + {/* Hero CTA — "Spiel starten" als dominanter zentraler Button */} setShowSettings(false)} /> + setShowOnboarding(false)} @@ -263,6 +310,14 @@ const styles = StyleSheet.create({ settingsIcon: { fontSize: 24, }, + mascotRow: { + marginTop: Spacing.sm, + gap: 2, + }, + mascotUnlockHint: { + fontSize: FontSize.xs, + marginLeft: 64, + }, heroSlot: { flex: 1, minHeight: 140, diff --git a/app/levels.tsx b/app/levels.tsx index 7fe7c5a..49ed42d 100644 --- a/app/levels.tsx +++ b/app/levels.tsx @@ -13,6 +13,7 @@ import { useTheme } from '@services/ThemeContext'; import { getAllLevels } from '@services/LevelManager'; import { getAvailablePacks } from '@services/ImagePoolManager'; import storageManager from '@services/StorageManager'; +import { getAgeGroup, getRecommendedLevelRange } from '@services/AgeGroupManager'; import Colors from '../constants/Colors'; import { Spacing, FontSize, FontWeight, FontFamily, BorderRadius } from '../constants/Layout'; import { GlassCard } from '@components/AnimatedPrimitives'; @@ -46,6 +47,7 @@ export default function LevelsScreen() { const [ratings, setRatings] = useState>({}); const [variant, setVariant] = useState('normal'); const [pack, setPack] = useState('all'); + const [recommendedRange, setRecommendedRange] = useState<[number, number] | null>(null); // Use hook for responsive dimensions (SSR-safe) const { width: screenWidth } = useWindowDimensions(); const cardWidth = @@ -73,6 +75,16 @@ export default function LevelsScreen() { }; }, [levels]); + useEffect(() => { + let mounted = true; + getAgeGroup().then(ageGroup => { + if (mounted && ageGroup) setRecommendedRange(getRecommendedLevelRange(ageGroup)); + }); + return () => { + mounted = false; + }; + }, []); + const renderStars = (rating: number | null, levelNumber: number) => { const filled = rating === null ? 0 : Math.ceil(rating / 2); return ( @@ -134,6 +146,14 @@ export default function LevelsScreen() { ← {t('common.back')} {t('levels.title')} + {recommendedRange && ( + + {t('ageGroup.recommendedLevels', { + from: String(recommendedRange[0]), + to: String(recommendedRange[1]), + })} + + )} {/* Spielvariante */} @@ -202,6 +222,11 @@ const styles = StyleSheet.create({ fontWeight: FontWeight.bold, fontFamily: FontFamily.extraBold, }, + recommendedHint: { + fontSize: FontSize.xs, + fontWeight: FontWeight.medium, + marginTop: 2, + }, variantSection: { paddingHorizontal: Spacing.lg, paddingTop: Spacing.md, diff --git a/components/AgeGroupModal.tsx b/components/AgeGroupModal.tsx new file mode 100644 index 0000000..d25bf68 --- /dev/null +++ b/components/AgeGroupModal.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useTranslation } from '@services/i18n'; +import { useTheme } from '@services/ThemeContext'; +import Mascot from './Mascot'; +import Colors from '../constants/Colors'; +import { BorderRadius, FontSize, FontWeight, FontFamily, Spacing } from '../constants/Layout'; +import type { AgeGroup } from '../types'; + +interface Props { + visible: boolean; + onConfirm: (ageGroup: AgeGroup) => void; +} + +const AGE_GROUPS: { id: AgeGroup; labelKey: string }[] = [ + { id: '3-5', labelKey: 'ageGroup.group3to5' }, + { id: '6-8', labelKey: 'ageGroup.group6to8' }, + { id: '9plus', labelKey: 'ageGroup.group9plus' }, +]; + +/** + * Erst-Start-Altersauswahl (Issue #279, 1.3). Ersetzt den früheren + * versteckten `extra_time_mode`-Schalter durch eine bewusste Entscheidung + * vor dem ersten Spiel — steuert Anzeigedauer, empfohlenen Level-Bereich + * und Standard-Strichstärke. Änderbar jederzeit in den Einstellungen. + */ +export default function AgeGroupModal({ visible, onConfirm }: Props) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [selected, setSelected] = useState(null); + + return ( + + + + + {t('ageGroup.title')} + + {t('ageGroup.subtitle')} + + + + + {AGE_GROUPS.map(group => { + const active = selected === group.id; + return ( + setSelected(group.id)} + style={[ + styles.optionCard, + { + backgroundColor: active ? Colors.primary : colors.surface, + borderColor: active ? Colors.primary : colors.border, + }, + ]} + accessibilityRole="button" + accessibilityState={{ selected: active }} + testID={`age-group-option-${group.id}`} + > + + {t(group.labelKey)} + + + ); + })} + + + + {t('ageGroup.changeLater')} + + + selected && onConfirm(selected)} + disabled={!selected} + style={[styles.confirmButton, !selected && styles.confirmButtonDisabled]} + accessibilityRole="button" + testID="age-group-confirm" + > + {t('ageGroup.confirm')} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: Spacing.xxl, + paddingBottom: Spacing.xl, + paddingHorizontal: Spacing.xl, + justifyContent: 'space-between', + }, + heroSection: { + alignItems: 'center', + gap: Spacing.sm, + }, + title: { + fontSize: FontSize.xl, + fontWeight: FontWeight.bold, + fontFamily: FontFamily.bold, + textAlign: 'center', + marginTop: Spacing.sm, + }, + subtitle: { + fontSize: FontSize.md, + textAlign: 'center', + lineHeight: FontSize.md * 1.4, + maxWidth: 300, + }, + optionsList: { + gap: Spacing.sm, + }, + optionCard: { + borderRadius: BorderRadius.lg, + borderWidth: 2, + paddingVertical: Spacing.md, + alignItems: 'center', + ...Colors.shadow.small, + }, + optionText: { + fontSize: FontSize.lg, + fontWeight: FontWeight.bold, + }, + changeLater: { + fontSize: FontSize.xs, + textAlign: 'center', + }, + confirmButton: { + backgroundColor: Colors.primary, + borderRadius: BorderRadius.round, + paddingVertical: Spacing.lg, + alignItems: 'center', + ...Colors.shadow.large, + }, + confirmButtonDisabled: { + opacity: 0.4, + }, + confirmButtonText: { + color: '#fff', + fontSize: FontSize.lg, + fontWeight: FontWeight.bold, + fontFamily: FontFamily.bold, + letterSpacing: 0.4, + }, +}); diff --git a/components/Mascot.tsx b/components/Mascot.tsx new file mode 100644 index 0000000..678d096 --- /dev/null +++ b/components/Mascot.tsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import Svg, { Circle, Ellipse, Path, Polygon } from 'react-native-svg'; +import Colors from '../constants/Colors'; +import { BorderRadius, FontSize, FontWeight, Spacing } from '../constants/Layout'; +import type { MascotMood } from '@services/MascotManager'; + +export type MascotAccessory = 'hat' | 'glasses' | 'bowtie' | 'crown'; + +interface MascotProps { + message?: string; + mood?: MascotMood; + size?: number; + accessory?: MascotAccessory | null; + onPress?: () => void; + testID?: string; + accessibilityLabel?: string; +} + +const MOOD_COLORS: Record = { + neutral: { body: '#4ECDC4', blush: false }, + happy: { body: '#44CF6C', blush: true }, + excited: { body: '#2ECC71', blush: true }, + encouraging: { body: '#7FD8CE', blush: false }, +}; + +/** + * Chamäleon-Begleitfigur ("Mali") — begrüßt, kommentiert Fortschritt und + * trägt kosmetische Unlocks (Issue #279, 1.1). Reine SVG-Zeichnung (kein + * Lottie-Asset — siehe docs/ILLUSTRATION_STYLEGUIDE.md), damit sie ohne + * externe Design-Assets in beide Plattformen (Native/Web) rendert. + */ +export default function Mascot({ + message, + mood = 'neutral', + size = 72, + accessory = null, + onPress, + testID = 'mascot', + accessibilityLabel, +}: MascotProps) { + const { body } = MOOD_COLORS[mood]; + const eyeOffsetY = mood === 'excited' ? -2 : 0; + + const svg = ( + + {/* Schwanz */} + + {/* Füße */} + + + {/* Körper */} + + {/* Kopf */} + + {/* Augenhöcker */} + + + {/* Augen (weiß + Pupille) */} + + + + + {/* Wangen-Blush bei fröhlicher Stimmung */} + {MOOD_COLORS[mood].blush && ( + <> + + + + )} + {/* Mund */} + {mood === 'encouraging' ? ( + + ) : ( + + )} + + {/* Accessoires (kosmetische Unlocks) */} + {accessory === 'hat' && ( + <> + + + + )} + {accessory === 'glasses' && ( + <> + + + + + )} + {accessory === 'bowtie' && ( + + )} + {accessory === 'crown' && ( + + )} + + ); + + const content = ( + + {svg} + {message ? ( + + {message} + + ) : null} + + ); + + if (!onPress) { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.sm, + }, + bubble: { + flexShrink: 1, + borderRadius: BorderRadius.lg, + paddingVertical: Spacing.xs, + paddingHorizontal: Spacing.sm, + ...Colors.shadow.small, + }, + bubbleText: { + fontSize: FontSize.sm, + fontWeight: FontWeight.semibold, + }, +}); diff --git a/components/MascotUnlockToast.tsx b/components/MascotUnlockToast.tsx new file mode 100644 index 0000000..f8a9400 --- /dev/null +++ b/components/MascotUnlockToast.tsx @@ -0,0 +1,126 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useReduceMotion } from '../utils/useReduceMotion'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withDelay, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { useTranslation } from '@services/i18n'; +import Colors from '../constants/Colors'; +import { BorderRadius, FontSize, FontWeight, Spacing } from '../constants/Layout'; +import type { MascotUnlock } from '@services/MascotManager'; + +interface Props { + unlock: MascotUnlock | null; + onHide: () => void; +} + +const VISIBLE_MS = 2800; +const FADE_MS = 280; + +/** + * Slide-in Toast für neu freigeschaltete Mascot-Accessoires (Issue #279, 1.1). + * Spiegelt BadgeUnlockToast, aber für das einheitliche Fortschrittssystem + * (Gesamt-Sterne → kosmetische Mascot-Unlocks) statt Achievements. + */ +export default function MascotUnlockToast({ unlock, onHide }: Props) { + const { t } = useTranslation(); + const reduceMotion = useReduceMotion(); + const translateY = useSharedValue(-80); + const opacity = useSharedValue(0); + + useEffect(() => { + if (!unlock) return; + + if (reduceMotion) { + translateY.value = 0; + } else { + translateY.value = withSequence( + withTiming(0, { duration: FADE_MS, easing: Easing.out(Easing.ease) }), + withDelay( + VISIBLE_MS, + withTiming(-80, { duration: FADE_MS, easing: Easing.in(Easing.ease) }), + ), + ); + } + opacity.value = withSequence( + withTiming(1, { duration: FADE_MS }), + withDelay(VISIBLE_MS, withTiming(0, { duration: FADE_MS })), + ); + + const timer = setTimeout(onHide, VISIBLE_MS + FADE_MS * 2 + 50); + return () => clearTimeout(timer); + }, [unlock, reduceMotion, translateY, opacity, onHide]); + + const animStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], + opacity: opacity.value, + })); + + if (!unlock) return null; + + return ( + + 🦎 + + + {t('mascot.unlockToast.title')} + + + {t('mascot.unlockToast.subtitle', { item: t(unlock.labelKey) })} + + + + ); +} + +const styles = StyleSheet.create({ + toast: { + position: 'absolute', + top: Spacing.lg, + left: Spacing.md, + right: Spacing.md, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: Spacing.md, + paddingHorizontal: Spacing.lg, + backgroundColor: Colors.surface, + borderRadius: BorderRadius.xl, + borderWidth: 1.5, + borderColor: Colors.accent, + shadowColor: Colors.accent, + shadowOpacity: 0.25, + shadowRadius: 12, + shadowOffset: { width: 0, height: 4 }, + elevation: 6, + zIndex: 1000, + }, + emoji: { + fontSize: 32, + marginRight: Spacing.md, + }, + textCol: { + flex: 1, + }, + title: { + fontSize: FontSize.xs, + fontWeight: FontWeight.bold, + color: Colors.accent, + textTransform: 'uppercase', + letterSpacing: 0.8, + }, + subtitle: { + fontSize: FontSize.md, + fontWeight: FontWeight.semibold, + color: Colors.text.primary, + marginTop: 2, + }, +}); diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 5ad903e..3da164c 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -15,12 +15,14 @@ import { useTranslation, getLanguage, setLanguage, type Language } from '@servic import { useTheme } from '@services/ThemeContext'; import storageManager from '@services/StorageManager'; import SoundManager from '@services/SoundManager'; +import { getAgeGroup, setAgeGroup } from '@services/AgeGroupManager'; import Colors from '../constants/Colors'; import { Spacing, FontSize, FontWeight, BorderRadius } from '../constants/Layout'; import ParentalGate from './ParentalGate'; import ParentDashboard from './ParentDashboard'; import BadgesModal from './BadgesModal'; import { useParentalGateAction } from './useParentalGateAction'; +import type { AgeGroup } from '../types'; interface SettingsModalProps { visible: boolean; @@ -42,6 +44,7 @@ export default function SettingsModal({ visible, onClose, embedded = false }: Se const [soundEnabled, setSoundEnabled] = useState(true); const [celebrationEnabled, setCelebrationEnabled] = useState(true); const [showParentDashboard, setShowParentDashboard] = useState(false); + const [ageGroup, setCurrentAgeGroup] = useState(null); const parentalGate = useParentalGateAction(); useEffect(() => { @@ -49,8 +52,14 @@ export default function SettingsModal({ visible, onClose, embedded = false }: Se setCurrentTheme(themeSetting); storageManager.getSetting('soundEnabled').then(setSoundEnabled); storageManager.getSetting('celebrationEnabled').then(setCelebrationEnabled); + getAgeGroup().then(setCurrentAgeGroup); }, [visible, themeSetting]); + const handleAgeGroupChange = async (value: AgeGroup) => { + setCurrentAgeGroup(value); + await setAgeGroup(value); + }; + const handleLanguageChange = async (lang: Language) => { await setLanguage(lang); setCurrentLang(lang); @@ -291,6 +300,18 @@ export default function SettingsModal({ visible, onClose, embedded = false }: Se {t('settings.personalize')} + {renderRow( + t('ageGroup.settingsLabel'), + renderSegment( + [ + { label: t('ageGroup.group3to5'), value: '3-5' }, + { label: t('ageGroup.group6to8'), value: '6-8' }, + { label: t('ageGroup.group9plus'), value: '9plus' }, + ], + ageGroup ?? '', + v => handleAgeGroupChange(v as AgeGroup), + ), + )} {renderRow( t('settings.celebration'), renderSegment( diff --git a/components/__tests__/HomeScreen.test.tsx b/components/__tests__/HomeScreen.test.tsx index a0d3b70..b541122 100644 --- a/components/__tests__/HomeScreen.test.tsx +++ b/components/__tests__/HomeScreen.test.tsx @@ -75,6 +75,31 @@ jest.mock('../../services/OnboardingManager', () => ({ markOnboardingDone: jest.fn(async () => {}), })); +jest.mock('../../services/AgeGroupManager', () => ({ + isAgeGroupSelected: jest.fn(async () => true), + setAgeGroup: jest.fn(async () => {}), +})); + +jest.mock('../../services/MascotManager', () => ({ + getMascotProgress: jest.fn(async () => ({ + totalStars: 0, + currentStreak: 0, + unlocked: [], + nextUnlock: null, + })), + getHomeGreetingKey: () => 'mascot.greeting.default', +})); + +jest.mock('../../components/Mascot', () => { + const { View } = require('react-native'); + return { __esModule: true, default: () => }; +}); + +jest.mock('../../components/AgeGroupModal', () => { + const { View } = require('react-native'); + return { __esModule: true, default: () => }; +}); + jest.mock('../../services/ThemeContext', () => ({ useTheme: () => ({ colors: { diff --git a/components/game/ResultPhase.tsx b/components/game/ResultPhase.tsx index ab26ccb..559783a 100644 --- a/components/game/ResultPhase.tsx +++ b/components/game/ResultPhase.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Modal } from 'react-native'; import DrawingCanvas from '@components/DrawingCanvas'; import LevelImageDisplay from '@components/LevelImageDisplay'; +import Mascot from '@components/Mascot'; import { AnimatedFeedback, AnimatedStar } from '@components/AnimatedPrimitives'; import Colors from '../../constants/Colors'; import { Spacing, FontSize, FontWeight, BorderRadius } from '../../constants/Layout'; @@ -10,6 +11,7 @@ import type { ResultPhaseProps } from './game.shared'; import { useTranslation } from '@services/i18n'; import { useTheme } from '@services/ThemeContext'; import { getRatingFeedback } from '@services/RatingManager'; +import { getResultMoodForStars } from '@services/MascotManager'; import type { StarRating } from '../../types'; export default function ResultPhase({ @@ -210,9 +212,16 @@ export default function ResultPhase({ ))} 0}> - - {getFeedbackText(userRating)} - + + + + {getFeedbackText(userRating)} + + @@ -303,10 +312,17 @@ const styles = StyleSheet.create({ gap: Spacing.sm, marginBottom: Spacing.md, }, + mascotFeedbackRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.sm, + paddingHorizontal: Spacing.md, + }, feedbackText: { + flexShrink: 1, fontSize: FontSize.md, - textAlign: 'center', - paddingHorizontal: Spacing.lg, + textAlign: 'left', }, completionOverlay: { flex: 1, diff --git a/locales/de/translations.json b/locales/de/translations.json index bd55de0..b244bef 100644 --- a/locales/de/translations.json +++ b/locales/de/translations.json @@ -270,5 +270,34 @@ "badgeMemorize": "Merken", "badgeDraw": "Zeichnen", "badgeResult": "Bewerten" + }, + "mascot": { + "greeting": { + "default": "Hallo! Ich bin Mali. Bereit zu spielen?", + "streak": "Schön, dass du wieder da bist! 🔥 {{count}} Tage in Folge!", + "streakLong": "Wow, {{count}} Tage in Folge! Du bist unglaublich!" + }, + "nextUnlock": "Noch {{count}} Sterne bis: {{item}}", + "accessory": { + "hat": "Hut", + "glasses": "Sonnenbrille", + "bowtie": "Fliege", + "crown": "Krone" + }, + "unlockToast": { + "title": "Neues Accessoire!", + "subtitle": "Mali trägt jetzt: {{item}}" + } + }, + "ageGroup": { + "title": "Wie alt ist dein Kind?", + "subtitle": "Das hilft uns, Anzeigezeit und Schwierigkeit passend einzustellen.", + "group3to5": "3–5 Jahre", + "group6to8": "6–8 Jahre", + "group9plus": "9+ Jahre", + "confirm": "Los geht's!", + "changeLater": "Du kannst das später jederzeit in den Einstellungen ändern.", + "settingsLabel": "Altersstufe", + "recommendedLevels": "Empfohlen für dein Kind: Level {{from}}–{{to}}" } } diff --git a/locales/en/translations.json b/locales/en/translations.json index 855d5b1..34e83a6 100644 --- a/locales/en/translations.json +++ b/locales/en/translations.json @@ -270,5 +270,34 @@ "badgeMemorize": "Remember", "badgeDraw": "Draw", "badgeResult": "Rate" + }, + "mascot": { + "greeting": { + "default": "Hi! I'm Mali. Ready to play?", + "streak": "Great to see you again! 🔥 {{count}} days in a row!", + "streakLong": "Wow, {{count}} days in a row! You're amazing!" + }, + "nextUnlock": "{{count}} more stars until: {{item}}", + "accessory": { + "hat": "Hat", + "glasses": "Sunglasses", + "bowtie": "Bow tie", + "crown": "Crown" + }, + "unlockToast": { + "title": "New accessory!", + "subtitle": "Mali is now wearing: {{item}}" + } + }, + "ageGroup": { + "title": "How old is your child?", + "subtitle": "This helps us set the display time and difficulty just right.", + "group3to5": "3–5 years", + "group6to8": "6–8 years", + "group9plus": "9+ years", + "confirm": "Let's go!", + "changeLater": "You can change this anytime later in settings.", + "settingsLabel": "Age group", + "recommendedLevels": "Recommended for your child: Level {{from}}–{{to}}" } } diff --git a/locales/es/translations.json b/locales/es/translations.json index 68c8aac..3d811e6 100644 --- a/locales/es/translations.json +++ b/locales/es/translations.json @@ -270,5 +270,34 @@ "badgeMemorize": "Memoriza", "badgeDraw": "Dibuja", "badgeResult": "Puntúa" + }, + "mascot": { + "greeting": { + "default": "¡Hola! Soy Mali. ¿Listo para jugar?", + "streak": "¡Qué bien que vuelvas! 🔥 ¡{{count}} días seguidos!", + "streakLong": "¡Vaya, {{count}} días seguidos! ¡Eres increíble!" + }, + "nextUnlock": "Faltan {{count}} estrellas para: {{item}}", + "accessory": { + "hat": "Sombrero", + "glasses": "Gafas de sol", + "bowtie": "Pajarita", + "crown": "Corona" + }, + "unlockToast": { + "title": "¡Nuevo accesorio!", + "subtitle": "Mali ahora lleva: {{item}}" + } + }, + "ageGroup": { + "title": "¿Cuántos años tiene tu hijo/a?", + "subtitle": "Esto nos ayuda a ajustar el tiempo de visualización y la dificultad.", + "group3to5": "3–5 años", + "group6to8": "6–8 años", + "group9plus": "9+ años", + "confirm": "¡Vamos!", + "changeLater": "Puedes cambiarlo más tarde en los ajustes.", + "settingsLabel": "Grupo de edad", + "recommendedLevels": "Recomendado para tu hijo/a: Nivel {{from}}–{{to}}" } } diff --git a/locales/fr/translations.json b/locales/fr/translations.json index 6547e66..0c62771 100644 --- a/locales/fr/translations.json +++ b/locales/fr/translations.json @@ -270,5 +270,34 @@ "badgeMemorize": "Mémorise", "badgeDraw": "Dessine", "badgeResult": "Note" + }, + "mascot": { + "greeting": { + "default": "Salut ! Je suis Mali. Prêt à jouer ?", + "streak": "Content de te revoir ! 🔥 {{count}} jours de suite !", + "streakLong": "Waouh, {{count}} jours de suite ! Tu es incroyable !" + }, + "nextUnlock": "Encore {{count}} étoiles avant : {{item}}", + "accessory": { + "hat": "Chapeau", + "glasses": "Lunettes de soleil", + "bowtie": "Nœud papillon", + "crown": "Couronne" + }, + "unlockToast": { + "title": "Nouvel accessoire !", + "subtitle": "Mali porte maintenant : {{item}}" + } + }, + "ageGroup": { + "title": "Quel âge a ton enfant ?", + "subtitle": "Cela nous aide à adapter le temps d'affichage et la difficulté.", + "group3to5": "3–5 ans", + "group6to8": "6–8 ans", + "group9plus": "9 ans et +", + "confirm": "C'est parti !", + "changeLater": "Tu peux changer cela plus tard dans les réglages.", + "settingsLabel": "Tranche d'âge", + "recommendedLevels": "Recommandé pour ton enfant : niveau {{from}}–{{to}}" } } diff --git a/locales/it/translations.json b/locales/it/translations.json index e716e8a..8f333fe 100644 --- a/locales/it/translations.json +++ b/locales/it/translations.json @@ -270,5 +270,34 @@ "badgeMemorize": "Memorizza", "badgeDraw": "Disegna", "badgeResult": "Valuta" + }, + "mascot": { + "greeting": { + "default": "Ciao! Sono Mali. Pronto a giocare?", + "streak": "Che bello rivederti! 🔥 {{count}} giorni di fila!", + "streakLong": "Wow, {{count}} giorni di fila! Sei incredibile!" + }, + "nextUnlock": "Ancora {{count}} stelle per: {{item}}", + "accessory": { + "hat": "Cappello", + "glasses": "Occhiali da sole", + "bowtie": "Papillon", + "crown": "Corona" + }, + "unlockToast": { + "title": "Nuovo accessorio!", + "subtitle": "Mali ora indossa: {{item}}" + } + }, + "ageGroup": { + "title": "Quanti anni ha tuo figlio/a?", + "subtitle": "Questo ci aiuta a impostare al meglio tempo di visualizzazione e difficoltà.", + "group3to5": "3–5 anni", + "group6to8": "6–8 anni", + "group9plus": "9+ anni", + "confirm": "Si comincia!", + "changeLater": "Puoi cambiarlo in qualsiasi momento nelle impostazioni.", + "settingsLabel": "Fascia d'età", + "recommendedLevels": "Consigliato per tuo figlio/a: Livello {{from}}–{{to}}" } } diff --git a/locales/nl/translations.json b/locales/nl/translations.json index 055fbdc..168481c 100644 --- a/locales/nl/translations.json +++ b/locales/nl/translations.json @@ -270,5 +270,34 @@ "badgeMemorize": "Onthouden", "badgeDraw": "Tekenen", "badgeResult": "Beoordelen" + }, + "mascot": { + "greeting": { + "default": "Hoi! Ik ben Mali. Klaar om te spelen?", + "streak": "Leuk dat je er weer bent! 🔥 {{count}} dagen op rij!", + "streakLong": "Wow, {{count}} dagen op rij! Je bent geweldig!" + }, + "nextUnlock": "Nog {{count}} sterren tot: {{item}}", + "accessory": { + "hat": "Hoed", + "glasses": "Zonnebril", + "bowtie": "Vlinderdas", + "crown": "Kroon" + }, + "unlockToast": { + "title": "Nieuw accessoire!", + "subtitle": "Mali draagt nu: {{item}}" + } + }, + "ageGroup": { + "title": "Hoe oud is je kind?", + "subtitle": "Dit helpt ons de weergavetijd en moeilijkheidsgraad goed in te stellen.", + "group3to5": "3–5 jaar", + "group6to8": "6–8 jaar", + "group9plus": "9+ jaar", + "confirm": "Laten we beginnen!", + "changeLater": "Je kunt dit later altijd wijzigen in de instellingen.", + "settingsLabel": "Leeftijdsgroep", + "recommendedLevels": "Aanbevolen voor je kind: Level {{from}}–{{to}}" } } diff --git a/locales/pl/translations.json b/locales/pl/translations.json index 7028c45..c4db58b 100644 --- a/locales/pl/translations.json +++ b/locales/pl/translations.json @@ -270,5 +270,34 @@ "badgeMemorize": "Zapamiętaj", "badgeDraw": "Rysuj", "badgeResult": "Oceń" + }, + "mascot": { + "greeting": { + "default": "Cześć! Jestem Mali. Gotowy do gry?", + "streak": "Miło Cię znów widzieć! 🔥 {{count}} dni z rzędu!", + "streakLong": "Wow, {{count}} dni z rzędu! Jesteś niesamowity!" + }, + "nextUnlock": "Jeszcze {{count}} gwiazdek do: {{item}}", + "accessory": { + "hat": "Kapelusz", + "glasses": "Okulary przeciwsłoneczne", + "bowtie": "Muszka", + "crown": "Korona" + }, + "unlockToast": { + "title": "Nowy dodatek!", + "subtitle": "Mali nosi teraz: {{item}}" + } + }, + "ageGroup": { + "title": "Ile lat ma Twoje dziecko?", + "subtitle": "To pomaga nam dopasować czas wyświetlania i trudność.", + "group3to5": "3–5 lat", + "group6to8": "6–8 lat", + "group9plus": "9+ lat", + "confirm": "Zaczynamy!", + "changeLater": "Możesz to później zmienić w ustawieniach.", + "settingsLabel": "Grupa wiekowa", + "recommendedLevels": "Polecane dla Twojego dziecka: Poziom {{from}}–{{to}}" } } diff --git a/services/AgeGroupManager.ts b/services/AgeGroupManager.ts new file mode 100644 index 0000000..67742d7 --- /dev/null +++ b/services/AgeGroupManager.ts @@ -0,0 +1,92 @@ +/** + * AgeGroupManager — Altersstufen-Auswahl (Issue #279, 1.3) + * Ersetzt den früheren versteckten `extra_time_mode`-Schalter durch eine + * bewusste Altersauswahl (3–5 / 6–8 / 9+) beim ersten Start. + * Steuert Anzeigedauer-Bonus, empfohlenen Level-Bereich (Bildkomplexität) + * und Standard-Strichstärke. + */ + +import { createPersistedJson } from './storage/persistedJson'; +import type { AgeGroup } from '../types'; + +interface AgeGroupState { + ageGroup: AgeGroup | null; +} + +const DEFAULT_STATE: AgeGroupState = { ageGroup: null }; + +const VALID_AGE_GROUPS: AgeGroup[] = ['3-5', '6-8', '9plus']; + +function isAgeGroupState(v: unknown): v is AgeGroupState { + if (typeof v !== 'object' || v === null) return false; + const s = v as Record; + return s.ageGroup === null || VALID_AGE_GROUPS.includes(s.ageGroup as AgeGroup); +} + +const store = createPersistedJson({ + key: '@merke_male:age_group', + defaultValue: DEFAULT_STATE, + isValid: isAgeGroupState, +}); + +/** + * Lädt die gewählte Altersstufe. `null` bedeutet: noch nicht ausgewählt + * (löst die Erst-Start-Abfrage auf dem Home-Screen aus). + */ +export async function getAgeGroup(): Promise { + const state = await store.load(); + return state.ageGroup; +} + +export async function setAgeGroup(ageGroup: AgeGroup): Promise { + await store.save({ ageGroup }); +} + +export async function isAgeGroupSelected(): Promise { + return (await getAgeGroup()) !== null; +} + +export async function resetAgeGroup(): Promise { + await store.reset(); +} + +/** + * Zusätzliche Anzeigezeit (analog zum früheren `extra_time_mode`-Bonus von + * +3 s in `LevelManager.getDisplayDuration`) für jüngere Kinder. + */ +export function getExtraTimeForAgeGroup(ageGroup: AgeGroup | null): boolean { + return ageGroup === '3-5'; +} + +/** + * Standard-Strichstärke beim Start einer Zeichenrunde je Altersstufe + * (Werte passend zu den drei Strichstärken-Optionen in DrawPhase: 2/3/5). + */ +export function getDefaultStrokeWidthForAgeGroup(ageGroup: AgeGroup | null): 2 | 3 | 5 { + switch (ageGroup) { + case '3-5': + return 5; + case '9plus': + return 2; + case '6-8': + default: + return 3; + } +} + +/** + * Empfohlener Level-Bereich je Altersstufe (Bildkomplexität-Steuerung). + * Bewusst keine harte Sperre — bestehende Level bleiben für alle spielbar, + * das ist nur eine Empfehlung auf dem Level-Auswahl-Screen. + */ +export function getRecommendedLevelRange(ageGroup: AgeGroup | null): [number, number] { + switch (ageGroup) { + case '3-5': + return [1, 8]; + case '6-8': + return [1, 14]; + case '9plus': + default: + return [1, 20]; + } +} diff --git a/services/MascotManager.ts b/services/MascotManager.ts new file mode 100644 index 0000000..2e5e02c --- /dev/null +++ b/services/MascotManager.ts @@ -0,0 +1,92 @@ +/** + * MascotManager — Begleitfigur + einheitliches Fortschrittssystem (Issue #279, 1.1) + * + * Konsolidiert bestehende Fortschritts-Signale (Sterne aus StorageManager, + * Streak aus StreakManager) hinter EINEM Fortschrittsbegriff: Gesamt-Sterne + * schalten kosmetische Mascot-Accessoires frei. Kein separates XP-System, + * keine zusätzliche Währung — bewusst nur eine Ressource (Sterne). + */ + +import storageManager, { type AppProgress } from './StorageManager'; +import { getStreakData } from './StreakManager'; + +export type MascotMood = 'neutral' | 'happy' | 'excited' | 'encouraging'; + +export interface MascotUnlock { + id: string; + starsRequired: number; + labelKey: string; // i18n key für den Accessoire-Namen +} + +/** + * Kosmetische Unlocks, aufsteigend nach Sterne-Schwelle sortiert. + * Bewusst nur Mascot-Accessoires (kein Pinselfarben-Unlock in dieser Runde, + * um die feste Zeichenfarbpalette — Issue-Kommentar in Colors.ts — nicht + * anzutasten). + */ +export const MASCOT_UNLOCKS: MascotUnlock[] = [ + { id: 'hat', starsRequired: 15, labelKey: 'mascot.accessory.hat' }, + { id: 'glasses', starsRequired: 40, labelKey: 'mascot.accessory.glasses' }, + { id: 'bowtie', starsRequired: 80, labelKey: 'mascot.accessory.bowtie' }, + { id: 'crown', starsRequired: 150, labelKey: 'mascot.accessory.crown' }, +]; + +export function getTotalStars(progress: Pick): number { + return Object.values(progress.levels).reduce((sum, l) => sum + l.bestRating, 0); +} + +export function getUnlockedMascotAccessories(totalStars: number): MascotUnlock[] { + return MASCOT_UNLOCKS.filter(u => totalStars >= u.starsRequired); +} + +export function getNextMascotUnlock(totalStars: number): MascotUnlock | null { + return MASCOT_UNLOCKS.find(u => totalStars < u.starsRequired) ?? null; +} + +/** + * Ermittelt neu freigeschaltete Accessoires zwischen zwei Sterne-Ständen + * (z.B. vor/nach dem Speichern einer neuen Bewertung). + */ +export function getNewlyUnlockedAccessories( + starsBefore: number, + starsAfter: number, +): MascotUnlock[] { + return MASCOT_UNLOCKS.filter(u => starsAfter >= u.starsRequired && starsBefore < u.starsRequired); +} + +export interface MascotProgress { + totalStars: number; + currentStreak: number; + unlocked: MascotUnlock[]; + nextUnlock: MascotUnlock | null; +} + +/** + * Lädt den vereinheitlichten Fortschritt, den die Mascot-Komponente + * anzeigt: Gesamt-Sterne, aktuelle Streak, freigeschaltete Accessoires. + */ +export async function getMascotProgress(): Promise { + const [progress, streak] = await Promise.all([storageManager.getProgress(), getStreakData()]); + const totalStars = getTotalStars(progress); + return { + totalStars, + currentStreak: streak.currentStreak, + unlocked: getUnlockedMascotAccessories(totalStars), + nextUnlock: getNextMascotUnlock(totalStars), + }; +} + +/** Begrüßungs-i18n-Key je nach aktueller Streak. */ +export function getHomeGreetingKey(currentStreak: number): string { + if (currentStreak >= 7) return 'mascot.greeting.streakLong'; + if (currentStreak >= 2) return 'mascot.greeting.streak'; + return 'mascot.greeting.default'; +} + +/** Mascot-Stimmung passend zur Sterne-Bewertung einer Runde. */ +export function getResultMoodForStars(stars: number): MascotMood { + if (stars >= 5) return 'excited'; + if (stars >= 4) return 'happy'; + if (stars >= 3) return 'neutral'; + return 'encouraging'; +} diff --git a/services/__tests__/AgeGroupManager.test.ts b/services/__tests__/AgeGroupManager.test.ts new file mode 100644 index 0000000..7faaf92 --- /dev/null +++ b/services/__tests__/AgeGroupManager.test.ts @@ -0,0 +1,56 @@ +/** + * AgeGroupManager Tests (Issue #279, 1.3) + */ +import { + getAgeGroup, + setAgeGroup, + isAgeGroupSelected, + resetAgeGroup, + getExtraTimeForAgeGroup, + getDefaultStrokeWidthForAgeGroup, + getRecommendedLevelRange, +} from '../AgeGroupManager'; + +describe('AgeGroupManager', () => { + beforeEach(async () => { + await resetAgeGroup(); + }); + + it('returns null before any age group is selected', async () => { + expect(await getAgeGroup()).toBeNull(); + expect(await isAgeGroupSelected()).toBe(false); + }); + + it('persists a selected age group', async () => { + await setAgeGroup('6-8'); + expect(await getAgeGroup()).toBe('6-8'); + expect(await isAgeGroupSelected()).toBe(true); + }); + + describe('getExtraTimeForAgeGroup', () => { + it('grants extra time only for 3-5', () => { + expect(getExtraTimeForAgeGroup('3-5')).toBe(true); + expect(getExtraTimeForAgeGroup('6-8')).toBe(false); + expect(getExtraTimeForAgeGroup('9plus')).toBe(false); + expect(getExtraTimeForAgeGroup(null)).toBe(false); + }); + }); + + describe('getDefaultStrokeWidthForAgeGroup', () => { + it('returns thick strokes for young kids and thin for older kids', () => { + expect(getDefaultStrokeWidthForAgeGroup('3-5')).toBe(5); + expect(getDefaultStrokeWidthForAgeGroup('6-8')).toBe(3); + expect(getDefaultStrokeWidthForAgeGroup('9plus')).toBe(2); + expect(getDefaultStrokeWidthForAgeGroup(null)).toBe(3); + }); + }); + + describe('getRecommendedLevelRange', () => { + it('narrows the range for younger age groups', () => { + expect(getRecommendedLevelRange('3-5')).toEqual([1, 8]); + expect(getRecommendedLevelRange('6-8')).toEqual([1, 14]); + expect(getRecommendedLevelRange('9plus')).toEqual([1, 20]); + expect(getRecommendedLevelRange(null)).toEqual([1, 20]); + }); + }); +}); diff --git a/services/__tests__/MascotManager.test.ts b/services/__tests__/MascotManager.test.ts new file mode 100644 index 0000000..84d3b5f --- /dev/null +++ b/services/__tests__/MascotManager.test.ts @@ -0,0 +1,74 @@ +/** + * MascotManager Tests (Issue #279, 1.1) + */ +import { + getTotalStars, + getUnlockedMascotAccessories, + getNextMascotUnlock, + getNewlyUnlockedAccessories, + getHomeGreetingKey, + getResultMoodForStars, + MASCOT_UNLOCKS, +} from '../MascotManager'; + +describe('MascotManager', () => { + describe('getTotalStars', () => { + it('sums bestRating across all levels', () => { + const progress = { + levels: { + 1: { levelNumber: 1, rating: 4, completedAt: '', bestRating: 4 }, + 2: { levelNumber: 2, rating: 3, completedAt: '', bestRating: 5 }, + }, + }; + expect(getTotalStars(progress)).toBe(9); + }); + + it('returns 0 for no completed levels', () => { + expect(getTotalStars({ levels: {} })).toBe(0); + }); + }); + + describe('getUnlockedMascotAccessories / getNextMascotUnlock', () => { + it('unlocks nothing below the first threshold', () => { + expect(getUnlockedMascotAccessories(5)).toEqual([]); + expect(getNextMascotUnlock(5)?.id).toBe(MASCOT_UNLOCKS[0].id); + }); + + it('unlocks accessories at and above their threshold', () => { + const unlocked = getUnlockedMascotAccessories(40); + expect(unlocked.map(u => u.id)).toEqual(['hat', 'glasses']); + }); + + it('returns null next unlock once everything is unlocked', () => { + expect(getNextMascotUnlock(9999)).toBeNull(); + }); + }); + + describe('getNewlyUnlockedAccessories', () => { + it('detects accessories crossed between before/after totals', () => { + const newly = getNewlyUnlockedAccessories(10, 42); + expect(newly.map(u => u.id)).toEqual(['hat', 'glasses']); + }); + + it('returns empty when no threshold was crossed', () => { + expect(getNewlyUnlockedAccessories(20, 25)).toEqual([]); + }); + }); + + describe('getHomeGreetingKey', () => { + it('escalates the greeting with streak length', () => { + expect(getHomeGreetingKey(0)).toBe('mascot.greeting.default'); + expect(getHomeGreetingKey(3)).toBe('mascot.greeting.streak'); + expect(getHomeGreetingKey(7)).toBe('mascot.greeting.streakLong'); + }); + }); + + describe('getResultMoodForStars', () => { + it('maps star ratings to a mascot mood', () => { + expect(getResultMoodForStars(5)).toBe('excited'); + expect(getResultMoodForStars(4)).toBe('happy'); + expect(getResultMoodForStars(3)).toBe('neutral'); + expect(getResultMoodForStars(1)).toBe('encouraging'); + }); + }); +}); diff --git a/services/__tests__/useGamePhase.test.ts b/services/__tests__/useGamePhase.test.ts index 1bf5c1c..771959b 100644 --- a/services/__tests__/useGamePhase.test.ts +++ b/services/__tests__/useGamePhase.test.ts @@ -40,9 +40,20 @@ jest.mock('../StorageManager', () => ({ saveLevelProgress: jest.fn().mockResolvedValue(undefined), saveToGallery: jest.fn().mockResolvedValue(undefined), getSettings: jest.fn().mockResolvedValue({ extraTimeMode: false }), + getProgress: jest.fn().mockResolvedValue({ + levels: {}, + lastPlayedLevel: 1, + totalLevelsCompleted: 0, + averageRating: 0, + }), }, })); +jest.mock('../AgeGroupManager', () => ({ + getAgeGroup: jest.fn().mockResolvedValue(null), + getExtraTimeForAgeGroup: jest.fn(() => false), +})); + jest.mock('../SoundManager', () => ({ __esModule: true, default: { diff --git a/services/useGamePhase.ts b/services/useGamePhase.ts index a5d2667..35af32e 100644 --- a/services/useGamePhase.ts +++ b/services/useGamePhase.ts @@ -10,6 +10,8 @@ import { recordSession } from '@services/SessionTracker'; import SoundManager from '@services/SoundManager'; import { requestReviewIfEligible } from '@services/ReviewManager'; import { useTimer } from '@services/useTimer'; +import { getAgeGroup, getExtraTimeForAgeGroup } from '@services/AgeGroupManager'; +import { getTotalStars, getNewlyUnlockedAccessories, type MascotUnlock } from '@services/MascotManager'; import type { DrawingPath } from '@components/DrawingCanvas'; import type { GamePhase, LevelImage } from '../types'; @@ -60,6 +62,7 @@ export function useGamePhase({ const levelStartedAtRef = useRef(Date.now()); const [extraTimeMode, setExtraTimeMode] = useState(false); + const [newMascotUnlock, setNewMascotUnlock] = useState(null); const REPLAY_DURATION_MS = 3000; const REPLAY_FRAME_MS = 30; @@ -77,11 +80,12 @@ export function useGamePhase({ onExpire: handleTimerExpire, }); - // Load extraTimeMode setting once on mount with unmount guard + // Load extra-time bonus from the selected age group (Issue #279, 1.3 — + // replaces the previous hidden extraTimeMode settings toggle). useEffect(() => { let mounted = true; - storageManager.getSettings().then(settings => { - if (mounted) setExtraTimeMode(settings.extraTimeMode); + getAgeGroup().then(ageGroup => { + if (mounted) setExtraTimeMode(getExtraTimeForAgeGroup(ageGroup)); }); return () => { mounted = false; @@ -192,7 +196,11 @@ export function useGamePhase({ try { SoundManager.playStarTap(rating); setUserRating(rating); + const starsBefore = getTotalStars(await storageManager.getProgress()); await storageManager.saveLevelProgress(levelNumber, rating); + const starsAfter = getTotalStars(await storageManager.getProgress()); + const newlyUnlocked = getNewlyUnlockedAccessories(starsBefore, starsAfter); + if (newlyUnlocked.length > 0) setNewMascotUnlock(newlyUnlocked[0]); await updateStreakAfterGame(); await recordSession({ durationMs: Date.now() - levelStartedAtRef.current, @@ -296,6 +304,8 @@ export function useGamePhase({ isReplaying, setIsReplaying, replayPaths, + newMascotUnlock, + clearMascotUnlock: () => setNewMascotUnlock(null), handleRatingSubmit, saveToGallery, startReplay, diff --git a/types/index.ts b/types/index.ts index f2c7080..d56d4ff 100644 --- a/types/index.ts +++ b/types/index.ts @@ -94,3 +94,11 @@ export interface BrushSettings { color: string; size: number; // 1 = dünn, 2 = mittel, 3 = dick } + +/** + * Altersstufe des Kindes (Issue #279, 1.3) — ersetzt den früheren + * versteckten `extra_time_mode`-Schalter durch eine bewusste Auswahl + * beim ersten Start. Steuert Anzeigedauer, empfohlenen Level-Bereich + * und Standard-Strichstärke. + */ +export type AgeGroup = '3-5' | '6-8' | '9plus'; From 2c48bf132f5188fe30ee573f8fb4ae63aa4e2c35 Mon Sep 17 00:00:00 2001 From: DevSven <184159789+S540d@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:01:50 +0200 Subject: [PATCH 08/86] =?UTF-8?q?docs:=20Illustrations-Stilguide=20f=C3=BC?= =?UTF-8?q?r=20Level-SVGs=20(Issue=20#279,=202.1)=20(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 1 + docs/ILLUSTRATION_STYLEGUIDE.md | 155 ++++++++++++++++++++++++++++++++ docs/README.md | 4 + 3 files changed, 160 insertions(+) create mode 100644 docs/ILLUSTRATION_STYLEGUIDE.md diff --git a/CLAUDE.md b/CLAUDE.md index 7ddf4a2..861b524 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -429,6 +429,7 @@ Stand: `main` @ v1.7.0 / versionCode 66. `testing` liegt voraus: enthält zusät - Pflicht nach jedem neuen SVG: `npm run validate:svg-counts` (derzeit 51 Einträge) - `getAvailablePacks()` (`ImagePoolManager.ts`) liefert alle im Pool vorkommenden Pack-IDs für die Chip-Filter-UI in `app/levels.tsx` - Neue Packs erscheinen automatisch als Filteroption — für ein sprechendes Label in der UI zusätzlich einen Eintrag in `PACK_LABEL_KEYS` (`app/levels.tsx`) sowie `levels.pack.