From 4b4dd27aa7bbd72373e081e9bf1bdc33b8f3685e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 18:42:17 +0000 Subject: [PATCH 1/6] Slovko: project scaffold, design system, data/domain/DI spine, navigation, notifications, widget Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KcutcC6MjorhCn8EAfHDcP --- .../.gradle/8.14.3/checksums/checksums.lock | Bin 0 -> 17 bytes .../executionHistory/executionHistory.bin | Bin 0 -> 20448 bytes .../executionHistory/executionHistory.lock | Bin 0 -> 17 bytes .../.gradle/8.14.3/fileChanges/last-build.bin | Bin 0 -> 1 bytes .../.gradle/8.14.3/fileHashes/fileHashes.bin | Bin 0 -> 18697 bytes .../.gradle/8.14.3/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes slovko/.gradle/8.14.3/gc.properties | 0 .../buildOutputCleanup.lock | Bin 0 -> 17 bytes .../buildOutputCleanup/cache.properties | 2 + .../buildOutputCleanup/outputFiles.bin | Bin 0 -> 18695 bytes slovko/.gradle/file-system.probe | Bin 0 -> 8 bytes slovko/.gradle/vcs-1/gc.properties | 0 slovko/DESIGN.md | 569 +++++++++++++++ slovko/app/build.gradle.kts | 91 +++ slovko/app/proguard-rules.pro | 18 + slovko/app/src/main/AndroidManifest.xml | 71 ++ .../src/main/java/com/slovko/MainActivity.kt | 48 ++ .../app/src/main/java/com/slovko/SlovkoApp.kt | 24 + .../java/com/slovko/core/common/AppClock.kt | 17 + .../com/slovko/core/common/Dispatchers.kt | 11 + .../com/slovko/core/common/TextNormalizer.kt | 29 + .../com/slovko/core/designsystem/Color.kt | 76 ++ .../com/slovko/core/designsystem/IconKeys.kt | 15 + .../com/slovko/core/designsystem/Motion.kt | 35 + .../com/slovko/core/designsystem/Theme.kt | 145 ++++ .../java/com/slovko/core/designsystem/Type.kt | 59 ++ .../core/designsystem/component/Components.kt | 374 ++++++++++ .../slovko/core/notification/Notifications.kt | 116 +++ .../src/main/java/com/slovko/data/AppJson.kt | 11 + .../java/com/slovko/data/ai/ChatPartner.kt | 98 +++ .../slovko/data/audio/PronunciationPlayer.kt | 43 ++ .../com/slovko/data/datastore/PrefKeys.kt | 35 + .../java/com/slovko/data/db/SlovkoDatabase.kt | 59 ++ .../java/com/slovko/data/db/dao/ChatDao.kt | 26 + .../java/com/slovko/data/db/dao/ContentDao.kt | 84 +++ .../com/slovko/data/db/dao/GamificationDao.kt | 51 ++ .../com/slovko/data/db/dao/ProgressDao.kt | 69 ++ .../java/com/slovko/data/db/dao/SrsDao.kt | 40 ++ .../slovko/data/db/entity/ContentEntities.kt | 97 +++ .../data/db/entity/GamificationEntities.kt | 56 ++ .../slovko/data/db/entity/ProgressEntities.kt | 69 ++ .../java/com/slovko/data/mapper/Mappers.kt | 169 +++++ .../data/repository/ChatRepositoryImpl.kt | 84 +++ .../data/repository/ContentRepositoryImpl.kt | 79 +++ .../repository/GamificationRepositoryImpl.kt | 225 ++++++ .../data/repository/ProgressRepositoryImpl.kt | 208 ++++++ .../data/repository/SettingsRepositoryImpl.kt | 80 +++ .../data/repository/SrsRepositoryImpl.kt | 91 +++ .../com/slovko/data/seed/ContentSeeder.kt | 108 +++ .../java/com/slovko/data/seed/SeedDtos.kt | 134 ++++ .../main/java/com/slovko/di/BindingsModule.kt | 52 ++ .../java/com/slovko/di/ProvidersModule.kt | 59 ++ .../com/slovko/domain/AchievementsCatalog.kt | 32 + .../com/slovko/domain/GamificationConfig.kt | 92 +++ .../com/slovko/domain/league/LeagueEngine.kt | 96 +++ .../com/slovko/domain/model/DomainModels.kt | 215 ++++++ .../java/com/slovko/domain/model/Enums.kt | 71 ++ .../java/com/slovko/domain/model/Settings.kt | 28 + .../slovko/domain/repository/Repositories.kt | 91 +++ .../java/com/slovko/domain/srs/FsrsParams.kt | 36 + .../com/slovko/domain/srs/FsrsScheduler.kt | 174 +++++ .../domain/usecase/GradeAnswerUseCase.kt | 48 ++ .../com/slovko/domain/usecase/XpCalculator.kt | 61 ++ .../main/java/com/slovko/ui/RootViewModel.kt | 39 ++ .../java/com/slovko/ui/navigation/Routes.kt | 24 + .../com/slovko/ui/navigation/SlovkoApp.kt | 156 +++++ .../com/slovko/ui/practice/PracticeScreen.kt | 157 +++++ .../slovko/ui/practice/PracticeViewModel.kt | 89 +++ .../com/slovko/widget/SlovkoWidgetReceiver.kt | 75 ++ .../main/java/com/slovko/work/BootReceiver.kt | 31 + .../com/slovko/work/NotificationScheduler.kt | 76 ++ .../src/main/java/com/slovko/work/Workers.kt | 89 +++ .../res/drawable/ic_launcher_background.xml | 12 + .../res/drawable/ic_launcher_foreground.xml | 28 + .../src/main/res/drawable/ic_stat_slovko.xml | 13 + .../main/res/drawable/widget_background.xml | 6 + .../app/src/main/res/layout/widget_slovko.xml | 44 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + slovko/app/src/main/res/values/colors.xml | 11 + slovko/app/src/main/res/values/strings.xml | 29 + slovko/app/src/main/res/values/themes.xml | 11 + .../src/main/res/xml/slovko_widget_info.xml | 11 + slovko/build.gradle.kts | 8 + .../reports/problems/problems-report.html | 663 ++++++++++++++++++ slovko/gradle.properties | 7 + slovko/gradle/libs.versions.toml | 66 ++ slovko/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + slovko/gradlew | 251 +++++++ slovko/gradlew.bat | 94 +++ slovko/settings.gradle.kts | 23 + 92 files changed, 6703 insertions(+) create mode 100644 slovko/.gradle/8.14.3/checksums/checksums.lock create mode 100644 slovko/.gradle/8.14.3/executionHistory/executionHistory.bin create mode 100644 slovko/.gradle/8.14.3/executionHistory/executionHistory.lock create mode 100644 slovko/.gradle/8.14.3/fileChanges/last-build.bin create mode 100644 slovko/.gradle/8.14.3/fileHashes/fileHashes.bin create mode 100644 slovko/.gradle/8.14.3/fileHashes/fileHashes.lock create mode 100644 slovko/.gradle/8.14.3/gc.properties create mode 100644 slovko/.gradle/buildOutputCleanup/buildOutputCleanup.lock create mode 100644 slovko/.gradle/buildOutputCleanup/cache.properties create mode 100644 slovko/.gradle/buildOutputCleanup/outputFiles.bin create mode 100644 slovko/.gradle/file-system.probe create mode 100644 slovko/.gradle/vcs-1/gc.properties create mode 100644 slovko/DESIGN.md create mode 100644 slovko/app/build.gradle.kts create mode 100644 slovko/app/proguard-rules.pro create mode 100644 slovko/app/src/main/AndroidManifest.xml create mode 100644 slovko/app/src/main/java/com/slovko/MainActivity.kt create mode 100644 slovko/app/src/main/java/com/slovko/SlovkoApp.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/common/AppClock.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/common/Dispatchers.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/common/TextNormalizer.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/designsystem/Color.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/designsystem/IconKeys.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/designsystem/Motion.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/designsystem/Theme.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/designsystem/Type.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt create mode 100644 slovko/app/src/main/java/com/slovko/core/notification/Notifications.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/AppJson.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/ai/ChatPartner.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/audio/PronunciationPlayer.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/datastore/PrefKeys.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/SlovkoDatabase.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/dao/ChatDao.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/dao/ContentDao.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/dao/GamificationDao.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/dao/ProgressDao.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/dao/SrsDao.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/entity/ContentEntities.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/entity/GamificationEntities.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/db/entity/ProgressEntities.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/mapper/Mappers.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/repository/ChatRepositoryImpl.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/repository/ContentRepositoryImpl.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/repository/GamificationRepositoryImpl.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/repository/ProgressRepositoryImpl.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/repository/SettingsRepositoryImpl.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/seed/ContentSeeder.kt create mode 100644 slovko/app/src/main/java/com/slovko/data/seed/SeedDtos.kt create mode 100644 slovko/app/src/main/java/com/slovko/di/BindingsModule.kt create mode 100644 slovko/app/src/main/java/com/slovko/di/ProvidersModule.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/AchievementsCatalog.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/GamificationConfig.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/league/LeagueEngine.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/model/DomainModels.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/model/Enums.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/model/Settings.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/repository/Repositories.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/srs/FsrsParams.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/srs/FsrsScheduler.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/usecase/GradeAnswerUseCase.kt create mode 100644 slovko/app/src/main/java/com/slovko/domain/usecase/XpCalculator.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/RootViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/navigation/Routes.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/navigation/SlovkoApp.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/practice/PracticeScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/practice/PracticeViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/widget/SlovkoWidgetReceiver.kt create mode 100644 slovko/app/src/main/java/com/slovko/work/BootReceiver.kt create mode 100644 slovko/app/src/main/java/com/slovko/work/NotificationScheduler.kt create mode 100644 slovko/app/src/main/java/com/slovko/work/Workers.kt create mode 100644 slovko/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 slovko/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 slovko/app/src/main/res/drawable/ic_stat_slovko.xml create mode 100644 slovko/app/src/main/res/drawable/widget_background.xml create mode 100644 slovko/app/src/main/res/layout/widget_slovko.xml create mode 100644 slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 slovko/app/src/main/res/values/colors.xml create mode 100644 slovko/app/src/main/res/values/strings.xml create mode 100644 slovko/app/src/main/res/values/themes.xml create mode 100644 slovko/app/src/main/res/xml/slovko_widget_info.xml create mode 100644 slovko/build.gradle.kts create mode 100644 slovko/build/reports/problems/problems-report.html create mode 100644 slovko/gradle.properties create mode 100644 slovko/gradle/libs.versions.toml create mode 100644 slovko/gradle/wrapper/gradle-wrapper.jar create mode 100644 slovko/gradle/wrapper/gradle-wrapper.properties create mode 100755 slovko/gradlew create mode 100644 slovko/gradlew.bat create mode 100644 slovko/settings.gradle.kts diff --git a/slovko/.gradle/8.14.3/checksums/checksums.lock b/slovko/.gradle/8.14.3/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..f246728927a1ef0ccff093794cdfd024108837d0 GIT binary patch literal 17 ScmZS9IsRNRvbm3s0SW*t(*t(^ literal 0 HcmV?d00001 diff --git a/slovko/.gradle/8.14.3/executionHistory/executionHistory.bin b/slovko/.gradle/8.14.3/executionHistory/executionHistory.bin new file mode 100644 index 0000000000000000000000000000000000000000..308a849dabbbd1a23a60ff688a8697de9bfa78da GIT binary patch literal 20448 zcmeI)Uue@n90%}ATS5FAL1o~?KPW0_GS})<5Oj9_!Rch(RQ58`79FA|h^HnpQg}b2uLqzJbey%jI`>pWLr6 zg#-!76NfSfaSy?*wr(WEdzN1>$h4z^00bZa0SG_<0uX=z1Rwwb2tWV=5P$##AOHaf zK;S^XZqW5_q6Y22xWg1_E7c?Zsbj-|Btu|2+G9)(U@~wc@>9&&mfczkFKtps(y<$GX7b z8&2?n2cDnvSr~sli}8gszg+KZ?CX1bWdD=nmp8eC4GOu5F8nUh#ScyIHuSzqQ=VF!{ER}R94I#j2PtjQZn^BmVab7m+jEkZ z_ToSA7pSL@wMvP7E~F?2TIOkeB$PlmhY>~LowS^X3a`!1}uP1DQ{#Jhy>S^k;)wmP9cA=hYm;&LuOU(7t*EnPXY z*GGe`?)&Zp58h1Zndb+ Ih_t$iUnDVg5C8xG literal 0 HcmV?d00001 diff --git a/slovko/.gradle/8.14.3/fileHashes/fileHashes.lock b/slovko/.gradle/8.14.3/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..7f24c4545dd8034cc78ca981c7be3acf701b8433 GIT binary patch literal 17 UcmZRsU;B4z(VeDW3=qHu07PyDk^lez literal 0 HcmV?d00001 diff --git a/slovko/.gradle/8.14.3/gc.properties b/slovko/.gradle/8.14.3/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/slovko/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/slovko/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000000000000000000000000000000000000..efcf25d365f9ce7dd6724be5f40d20788b4673ce GIT binary patch literal 17 UcmZR+wm|Tnb-G0+0|f8^05Dhsx&QzG literal 0 HcmV?d00001 diff --git a/slovko/.gradle/buildOutputCleanup/cache.properties b/slovko/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..ab06bb5 --- /dev/null +++ b/slovko/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Sat Jun 27 18:17:31 UTC 2026 +gradle.version=8.14.3 diff --git a/slovko/.gradle/buildOutputCleanup/outputFiles.bin b/slovko/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000000000000000000000000000000000000..8ad07905e5f0db2c36d56e92105e44f8d380b384 GIT binary patch literal 18695 zcmeI%KS%;m9Ki9XTiGAc5?g9(vJe_h6o`D7u#HFaSfM<0tcfdXl*D7qQPv4 zsI8WBYX}0RXsFH2sAu>4Dbf}M5#I;z?(seDaqqKU!kDoCLx$;$;m(p30tg_000Iag zfB*srAbvkY4vN;fS}j7|a **Slovko** — *"Po našom."* (In our way / the way we speak.) +> An offline-first, beautiful, gamified Android app for learning **modern spoken Slovak**, A0 → A2, with chatting-with-friends as the north star. + +--- + +## Table of Contents + +1. [Vision & Product Principles](#1-vision--product-principles) +2. [The Daily Loop ("Daily Brew")](#2-the-daily-loop-daily-brew) +3. [Learning Design & Curriculum](#3-learning-design--curriculum) +4. [Spaced Repetition (FSRS)](#4-spaced-repetition-fsrs) +5. [Exercise Types & Grading](#5-exercise-types--grading) +6. [Chat / Conversation Track](#6-chat--conversation-track) +7. [Gamification System](#7-gamification-system) +8. [Visual & UX Design System](#8-visual--ux-design-system) +9. [Screen-by-Screen](#9-screen-by-screen) +10. [Accessibility](#10-accessibility) +11. [Technical Architecture](#11-technical-architecture) +12. [Data Layer (Room schema)](#12-data-layer-room-schema) +13. [Content Shipping & Seeding](#13-content-shipping--seeding) +14. [Notifications & Retention](#14-notifications--retention) +15. [Widget & Quick Actions](#15-widget--quick-actions) +16. [Build, Versions & CI](#16-build-versions--ci) +17. [Resolved Cross-Section Decisions](#17-resolved-cross-section-decisions) + +--- + +## 1. Vision & Product Principles + +Slovko teaches the Slovak people actually speak — texting, café orders, banter — not textbook formal register. The product is a small, daily, ~7-minute ritual that compounds into real conversational ability. + +**Pillars** +- **Spoken-first, modern register by default.** Formal (*vykanie*) is a labeled variant, never the baseline. +- **Offline-first.** 100% of learning, progress, SRS, streaks, achievements and chat transcripts live on-device. Fully functional in airplane mode. The *only* optional network touchpoint is a user-supplied AI chat partner. +- **Reward effort, never punish learning.** Mistakes are data; the default model never locks a learner out. +- **English scaffolding, Slovak personality.** Slovak words are always the visual heroes — bigger, colored, never buried in English. +- **Habit by design.** Same cue, same ritual, same reward; a fixed-shape daily session with a bounded time ceiling. +- **Ethical gamification.** Variable rewards add texture only; nothing purchasable gates learning; no shame copy. + +**Brand voice:** Warm, casual, witty — a Slovak friend texting you. Correct → *"Presne tak! Nailed it."* Wrong → *"Skoro. Try this one again — you've got it."* (never "Wrong!"). + +**Mascot — Líška Maja**, a small modern fox (Slavic trickster-but-kind, clever with words). Rust-orange (`#E2603B`), cream belly (`#FFF3E6`), one charcoal ear tip, and a signature woven **Čičmany-pattern scarf**. 8 poses: waving, thinking, celebrating, sleepy, proud, worried, listening, reading. She speaks like a friend, not a teacher. + +--- + +## 2. The Daily Loop ("Daily Brew") + +One session = one **Daily Brew** (~7 min ceiling). Fixed shape so the brain stops deciding and just shows up. + +| Phase | Time | Content | Purpose | +|---|---|---|---| +| 1. Warm-up | 0:45 | 3 SRS review cards (fast, known) | Instant win / dopamine | +| 2. Core lesson | 4:00 | 1 skill node = 8–12 exercises, mixed types | New material in context | +| 3. SRS review | 1:30 | 6–10 due cards | The actual learning engine | +| 4. Chat moment | 0:45 | 1 micro-dialogue or texting phrase | Goal-relevant reward | +| Wrap | 0:15 | Streak +1, XP tally, "+3 words you can now text" | Progress made visible | + +Why it works: low activation energy (bounded ~7 min), variable-but-bounded reward (XP/streak), and the closing Chat moment ties every session to the real goal — chatting with friends. **Streak freeze** (2 banked, earn 1 per 7-day streak) prevents all-or-nothing collapse. + +--- + +## 3. Learning Design & Curriculum + +Linear-with-branches **skill tree**. Each **Unit** ≈ a CEFR can-do cluster → 3–5 **Skills** (crown-able nodes) → each Skill = 4–6 **Lessons** → each Lesson = 8–12 **Exercises**. Modern spoken register is default; formal forms are labeled variants. + +### CEFR Curriculum A0 → A2 + +- **Unit 0 — Zvuky (Sounds) [A0]** — pre-vocab phonics. Skills: *Alphabet & diacritics* (dĺžeň/mäkčeň), *The hard ones* (ä, ô, ľ, ď, ť, ň, dz/dž), *Stress always on syllable 1*, *Read-aloud drills*. Pure listening + speaking, no SRS pressure. +- **Unit 1 — Ahoj! [A0–A1]** — *ahoj/čau/nazdar* vs *dobrý deň*; *Ako sa máš?/máte?*; *Volám sa…*; *Ďakujem/prosím/prepáč*; Texting openers. First *vykať/tykať* contrast. +- **Unit 2 — Ja a ty [A1]** — pronouns; *byť* present; nationalities/*Som z…*; *Hovoríš po anglicky?*; numbers 0–20; *hej* vs *áno*. +- **Unit 3 — Rodina [A1]** — family nouns + diminutives (*mama→mamka*); possessives (*môj/moja/moje*); *Mám…* (accusative intro); age; pets. +- **Unit 4 — Jedlo & Káva [A1]** — foods/drinks; *Chcem/Dám si…*; *Máte…?*; café & krčma phrases; *Mám rád/rada*; accusative of food objects. +- **Unit 5 — V meste [A1–A2]** — places; *Kde je…?*; directions; prepositions + locative (*v meste*); transport (instrumental, *idem autobusom*); *Idem do/na…*. +- **Unit 6 — Čas & Plány [A2]** — clock & *o koľkej?*; days/months; making plans; **verb aspect intro** (*robiť/urobiť*, *ísť/prísť*). Texting: *o 5ke*, *si voľný?*. +- **Unit 7 — Každý deň [A2]** — routine verbs; reflexives (*umývam sa*); frequency adverbs; modals (*musím/chcem/môžem*); aspect drilled. +- **Unit 8 — Nakupovanie [A2]** — *Koľko to stojí?*; euros/cents; clothing/sizes; *Môžem zaplatiť kartou?*; genitive of quantity (*dve kávy*). +- **Unit 9 — Počasie & Pocity [A2]** — weather; *Je mi…* (dative of state); emotions; small talk; reactions. +- **Unit 10 — Práca & Škola [A2]** — jobs/studies; *Čím si?/Kde robíš?*; instrumental of profession. +- **Unit 11 — Cestovanie [A2]** — train/bus/booking; accommodation; emergencies; **past tense intro** (*bol som, kúpil som*). +- **Unit 12 — Píšeme si [A2, capstone]** — pulls everything into real WhatsApp-style conversations (the "Chat with Friends" north-star unit, tied to the **Kamarát** badge). + +### Slovak-Specific Challenges (taught explicitly) + +- **Pronunciation:** Unit 0 + recurring "Zvuk dňa" micro-lessons; minimal-pair drills (*ľúbiť/lúka*, *mäso/päť*, *kôň/stôl*). Rule: **mäkčeň** softens (d→ď), **dĺžeň** lengthens — with audio A/B. +- **6 cases, one per need** (never paradigm dumps), ordered by utility: Nominative → Accusative (U3/U4) → Locative (U5) → Instrumental (U5/U10) → Genitive (U8) → Dative (U9). Each gets `fill-in-the-case` exercises and a **color-coded case chip** UI, always anchored to a function. +- **Verb aspect** (hardest A2): process vs. result, paired verbs, visual (imperfective = ongoing arrow; perfective = checkmark). Intro U6, drilled U7/U11; exercises force aspect by context. +- **vykať vs tykať:** persistent **formality toggle** on dialogues. Rule: friends/peers/online = *tykať*; strangers/elders/officials = *vykať*. Chat track is mostly *tykať*; a dedicated "Formal mode" skill covers *vykanie*. + +A card is **only "born"** (added to SRS) after the learner has *produced* it once correctly in-lesson — never from passive exposure. + +--- + +## 4. Spaced Repetition (FSRS) + +**We use FSRS (v4/v5), not SM-2.** FSRS models per-card memory **stability + difficulty** and schedules at a target retention; for vocabulary with wildly varying difficulty it yields ~20–30% fewer reviews at equal retention. It is a pure deterministic function of review history → fully offline, on-device. *(This supersedes the SM-2 fields in the original arch draft — see [§17](#17-resolved-cross-section-decisions).)* + +**Card states:** `New → Learning → Review → Relearning`. Each card stores `stability`, `difficulty`, `due`, `lastReview`, `reps`, `lapses`, `state`. + +**Grades (4 buttons):** `Again(1) / Hard(2) / Good(3) / Easy(4)`. Auto-grade from exercises: correct first try → `Good`; correct with hint/slow → `Hard`; wrong → `Again`; trivially fast → `Easy`. + +**Steps & targets** +- Learning steps (intra-day): `Again → 1 min`, `Good → 10 min`, then graduate (FSRS computes first interval from initial stability). +- Target retention **0.90** (user-adjustable 0.85–0.95). +- Relearning step on lapse: `10 min`, then FSRS post-lapse stability. +- Daily caps: **15 new/day** (presets 8/12/20), **120 review cap**. New cards gated behind review load: if >80 reviews due, suppress new cards that day. + +**Mix:** Phase 2 introduces new cards interleaved with immediate practice (i+1). Phases 1 & 3 pull pure due cards (`due <= now`, ordered by overdue ratio). + +**Granularity:** one card per `(lemma, sense, direction)`. Recognition (SK→EN) graduates before production (EN→SK) unlocks. Grammar patterns get "concept cards" tested via fill-in-the-case / aspect-choice. + +The FSRS scheduler is implemented as a pure-Kotlin, JVM-unit-testable engine in `domain` (`FsrsScheduler`), independent of Android. + +--- + +## 5. Exercise Types & Grading + +| Type (DB enum) | Tests | SRS grade source | +|---|---|---| +| `MCQ` (SK→EN / EN→SK / image) | recognition | first-try → Good | +| `LISTEN_CHOOSE` | aural recognition | yes | +| `LISTEN_TYPE` | spelling + diacritics | strict (diacritics count) | +| `WORD_BANK` | syntax/word order/agreement | yes (production) | +| `TRANSLATE_EN_SK` (free type) | full production | strictest | +| `TRANSLATE_SK_EN` | comprehension | yes | +| `SPEAK` (on-device ASR or self-rated) | output, hard phonemes | self-grade Hard/Good | +| `MATCH_PAIRS` | form↔meaning linking | low weight | +| `FILL_CASE` | morphology | concept cards | +| `ASPECT_CHOICE` | aspect | concept cards | +| `DIALOGUE_FILL` | pragmatic use | participation | + +Each Skill declares a weighted exercise pool: `Skill ⟶ {exerciseType: weight}`. + +**Grading** lives in domain (`GradeAnswerUseCase`): diacritic-insensitive, whitespace/punctuation-normalized matching against an `acceptable` set (learners won't type č/š/ž early; word order is flexible). `LISTEN_TYPE` is strict on diacritics where the lesson teaches them. + +--- + +## 6. Chat / Conversation Track + +A parallel, always-accessible track (unlocked after U1) — the app's north star. Two components: + +**1. Scenario dialogues** (branching, replayable, formality-toggle, 6–12 turns): *Making plans, At the café, Running late, Weekend recap, Light banter, Asking a favor, Group-chat reactions.* Learner picks replies from a word bank, then graduates to free-type. Natural-speed audio + optional 0.7× slow replay. + +**2. Texting phrasebook** (searchable, copyable, SRS-eligible) — modern WhatsApp Slovak: *čau, čo robíš?, daj vedieť, dohodnuté, hej/jasné/v pohode, no nevadí, vďaka/dik, meškám trochu, sorry/prepáč, vidíme sa, maj sa/pa, haha/xd, no (filler)*. Conventions taught explicitly: dropped diacritics (*caw*), English loanwords (*sorry, ok, lol*), vowel-stretching (*ahojkaaa*), diminutive overload as friendliness. + +**Partner abstraction:** `ChatPartner` interface with `suspend fun reply(history): Result`. **Default build = on-device scripted branching partner** (deterministic responses from `ChatScenario` + intent matching) — fully offline. Optional Retrofit impl is opt-in via Settings; the user supplies endpoint/key (stored in DataStore). A subtle "naturalness" toast rewards colloquial choices. + +--- + +## 7. Gamification System + +All constants live in a single `GamificationConfig.kt` (tunable without touching logic). All loops resolve **on-device**. + +### XP (earned, never spent) + +| Exercise | Base XP | +|---|---| +| Tap-to-translate / word bank | 6 | +| Listening | 8 | +| Type-what-you-hear | 9 | +| Speaking | 10 | +| Conversation turn | 12 | +| SRS review card | 5 | + +```kotlin +object XpRules { + const val LESSON_COMPLETE_BONUS = 10 + const val PERFECT_LESSON_BONUS = 15 + const val FAST_LESSON_BONUS = 8 + const val COMBO_STEP = 1 // +1/correct beyond 3 + const val COMBO_MAX_BONUS = 10 + const val FIRST_LESSON_OF_DAY = 5 +} +``` +A 12-exercise lesson pays ~90–110 XP. **Level curve:** `xpForLevel(n) = 50*n^2 + 50*n` (front-loaded dopamine, level ring around avatar). + +**Daily XP goals** (a floor that defines a "win", never auto-escalated): Casual 30 / Regular 60 / Serious 120 / Intense 250. + +### Streaks (the #1 retention lever) +A streak day = **meeting the selected daily XP goal** (not merely opening). Day boundary = local midnight + 3h grace. + +```kotlin +object StreakRules { + const val GRACE_HOURS = 3 + const val FREEZE_MAX_HELD = 2 + const val FREEZE_GEM_COST = 200 + const val REPAIR_WINDOW_DAYS = 2 + const val REPAIR_GEM_COST = 350 + const val WEEKEND_AMULET_COST = 100 +} +``` +- **Streak Freeze:** auto-consumed on a missed day (preserves count, does *not* count as a learning day; UI: "Streak protected — jump back in today"). +- **Streak Repair:** within 2 days, free via a "comeback" lesson (offered first) or 350 gems. +- **Weekend Amulet:** 100 gems; one weekend day covers the other. +- Milestone confetti + rewards at 3/7/14/30/50/100/365 (gems, freezes, badges, chests). + +### Focus model (default) — **Hearts rejected as the default** +No lockout, no life loss. Each mistake: (1) re-queues the item later in the same lesson (mastery, not punishment), (2) schedules it sooner in SRS. A **Perfect Lesson** grants bonus XP — accuracy is *aspirational*, not *enforced*. An opt-in **Challenge Mode** offers 3 hearts + 1.5× XP for users who want tension. + +```kotlin +object MistakeRules { + const val REQUEUE_AFTER_N_ITEMS = 3 + const val MAX_REQUEUE = 2 + const val CHALLENGE_MODE_HEARTS = 3 + const val CHALLENGE_MODE_XP_MULT = 1.5 +} +``` + +### Leagues (backend-free, simulated, honest) +Week-long league, cohort of 15 = user + 14 deterministic **bots**. Tiers Bronze→Silver→Gold→Sapphire→Ruby→Emerald→Diamond; top 5 promote, bottom 4 demote. Bot `dailyMeanXp` is seeded each week clustered around the user's trailing 7-day average (`MEAN_FACTOR_RANGE = 0.55..1.35`, `MIN_BOT_MEAN = 20`) so the race is always winnable. Bot XP accrues **lazily from elapsed time on app open** (no background work). Disclosed honestly: "Your league is a private practice cohort." A separate **personal-bests** board always exists. + +### Achievements (15, Slovak-flavored) +Prvé slovo, Ranné vtáča, Nočná sova, Bezchybný (5/25/100), Ohnivák (7/30/100), Ukecaný, Dobré ucho, Hlas národa, Slovník (500/1500/3000), Maratónec, Týždeň víťazstva, Povýšený, Verný, Zberateľ, **Kamarát** (complete the Chat-with-Friends unit — north-star milestone). Grayscale locked → colored + burst + 10–50 gems on unlock. + +### Daily Quests (3 rotating) +Refresh at local midnight; one volume + one skill + one flavor, weighted by tier. Completing all 3 fills the **Daily Chest** (variable-ratio). Weekly meta ("5 of 7 days") grants a rare chest + freeze. + +### Variable-ratio rewards (ethical guardrails — hard rules in code comments) +**Gems** = spendable soft currency (freezes, repairs, amulets, cosmetics, optional Challenge entries). **Chests** loot XP/gems only (no pay-to-win). Hard rules: no real-money loot boxes; no FOMO timers on learning; no streak-shaming; loss-aversion reminders factual & ≤2/day; learning path fully deterministic and always available. + +### End-of-Lesson Celebration (≤3–4s, skippable, reduce-motion collapses to instant) +Card slide-in → XP count-up → staggered bonus rows → combo flare → streak panel (flame grows, milestone confetti) → quest/chest bars → league strip tick → CTA ("Continue" + "Done for today"). Timing constants in `CelebrationTiming`. + +--- + +## 8. Visual & UX Design System + +### Color (Material 3 tonal) — warm earthy-premium, distinctly Central-European + +**Light:** Primary Folk Red `#C0392B` / On `#FFFFFF` / Container `#FFDAD4` / OnContainer `#410001`; Secondary Pine `#2E6E4E` / Container `#B4F1C8`; Tertiary Sky Slate `#3E6B8C`; Accent/Streak Gold `#F2A900` / On-Gold `#3A2A00`; Success `#2E7D32`; Error `#BA1A1A`; Background/Surface `#FFFBF5`; Surface Container `#F7EFE3`; Surface Container High `#F1E6D6`; On-Surface `#231A14`; On-Surface Variant `#5A5046`; Outline `#857466`. + +**Dark (Tatra night, warm charcoal):** Primary `#FFB4A8` / On `#690002` / Container `#93000A`; Secondary `#99D4AD` / Container `#0F5132`; Tertiary `#A6CBEF`; Gold `#FFC93C`; Success `#7FCB83`; Error `#FFB4AB`; Background/Surface `#1A140F`; Surface Container `#271E18`; High `#332821`; On-Surface `#EFE0D5`; Variant `#D6C3B5`; Outline `#9F8D7E`. + +**Folk-art tokens:** Čičmany ornament line `#E6D2C0` (light) / `#3D3026` (dark) at 6% opacity behind hero areas and as the skill-tree path texture. Celebration gradient `#F2A900 → #C0392B`. Dynamic color on Android 12+ with this scheme as the custom fallback. + +### Typography (Google Fonts) +- **Display/headings: Fraunces** (literary serif) — screen titles, the big Slovak target word, celebration numbers. +- **Body/UI: Inter** — excellent diacritic rendering (ľ, š, č, ô); tabular figures for streak/XP. + +| Token | Font | Size/Line | Weight | +|---|---|---|---| +| Display L | Fraunces | 44/50 | 600 | +| Display M (target word) | Fraunces | 34/40 | 600 | +| Headline | Fraunces | 26/32 | 600 | +| Title | Inter | 20/26 | 600 | +| Body L | Inter | 17/24 | 400 | +| Body M | Inter | 15/22 | 400 | +| Label | Inter | 13/16 | 600 (+0.4 tracking) | + +### Foundations +- **Spacing 4dp grid:** 4/8/12/16/24/32/48/64; screen edge 20dp; card padding 16dp; section rhythm 24dp. +- **Radii:** xs 8 (chips) · sm 12 (buttons) · md 20 (cards) · lg 28 (sheets/dialogs) · full (pills/FAB/avatars). +- **Elevation:** tonal surface layers over shadows; cards = Surface Container (no rest shadow); pressed = High + 1dp; bottom nav = 3dp tonal; dialogs = lg radius + 40% scrim. +- **Motion (springy):** default spring `dampingRatio 0.7, stiffness 380`; button press scale 0.96/90ms; correct-answer pulse 1.0→1.06 + green sweep; node unlock 1.15 overshoot + gold shimmer; page transitions shared-axis X 250ms; physics confetti (Čičmany/diamond/heart particles). **All gated behind Reduced Motion** → springs become 120ms fades, confetti becomes a static badge. + +### Component Inventory +Primary Button (56dp, radius 12, 3dp darker "ledge" `#9B2C20` that compresses on press); Secondary/Ghost (2dp outline); Lesson Card (88dp, medallion icon, progress ring); Skill-Tree Node (72dp circle: locked/available-breathing/complete-gold-crown, connected by Čičmany-stitch dashed path that fills gold); Progress Ring (4dp stroke); XP Bar (gold pill, floating `+10`); Streak Flame (gold→red, 2px y-bob flicker; frozen = cyan ice `#7FB3D5`); Hearts (Challenge Mode only — shatter + shake on loss); Exercise Option Chip (selected/correct/wrong states + optional number key); Bottom Nav (5 items, pill indicator, labels always visible); Celebration Screen (honey→madder, Maja, count-up stats); Word/Phrase Tile (pill + speaker icon → TTS). + +--- + +## 9. Screen-by-Screen + +**Onboarding** (5 swipeable steps, progress dots, bottom-docked primary, no account): 1) Maja waving hero; 2) Why (goal chips — selecting "Chat with friends" themes copy toward conversation); 3) Level (beginner/some/get-by, phrase previews); 4) Daily goal (segmented, Maja reacts); 5) Notification opt-in (friendly time picker, default 19:00) → confetti → Home. + +**Home — Learn (skill tree):** vertical winding path over a stylized Tatra silhouette (subtle parallax). Sticky top bar: streak flame · gold/XP · (Challenge hearts if on) · settings gear. Next node auto-centered with "START" bubble + Maja peeking. Unit section headers as Fraunces banners with Čičmany divider. Tap node → bottom sheet (intro, est. time, Start). + +**Lesson Player:** top segmented progress bar (one per exercise) + close X (→ "Save progress?" sheet). Body changes per exercise type. Persistent bottom action zone: "Check" → slides up colored result banner ("Správne!"/"Skoro") + explanation + auto-playing correct audio → "Continue." + +**Exercise screens:** Translate (tap-to-build word bank, reorderable); MCQ (big Fraunces target, 2–4 chips, audio); Listen & choose (speaker tile, slow 0.7× toggle); Speak (waveform circle, skippable); Match pairs (two columns, fade on match); Fill-the-gap (faux chat bubble teaching register). + +**Lesson-Complete:** honey→madder takeover; three count-up stat cards (XP · Accuracy% · Time); flame leap if streak extended; Maja proud; confetti; Continue → path node animates to gold, stitch fills forward. + +**Profile/Stats:** header card (avatar with unlockable Maja frames, "Member since", big streak flame); **calendar heatmap** (gold tones); stat tiles (XP, words, lessons, longest streak); **achievement shelf** (folk-medallion badges); league rank summary. + +**Leaderboard:** weekly league as a folk-festival ranking; league name + days-remaining banner; rows (rank/avatar/name/XP tabular); user row pinned + highlighted; promotion (green) / demotion (red) zones marked; low-data state uses Maja "thinking" + honest tooltip. + +**Chat / Conversation Practice (flagship):** real messaging-app look (incoming = Surface Container left; yours = Primary Container right). Scenario picker first. Two modes: **Guided** (suggested-reply chips, long-press for English hint) and **Free** (type/speak; dynamic if AI plugin installed, scripted branching otherwise). Tap a message → TTS; long-press → translation + grammar note; naturalness toast for colloquial choices. + +**Settings:** grouped lg-radius sections — *Learning* (daily goal, target retention, SFX, autoplay audio, speaking on/off, Challenge Mode); *Notifications* (reminder time + "Let Slovko pick", streak/review/re-engage toggles, quiet hours, link to OS channel settings); *Appearance* (Light/Dark/System, Reduced Motion, system font scale); *Account & Data* (export/backup progress JSON, wipe); *AI partner* (optional pluggable card); *About*. + +--- + +## 10. Accessibility + +- **Contrast:** all on-color pairs meet WCAG AA (≥4.5:1 text, ≥3:1 large/UI). Never body text on gold; gold uses `#3A2A00` on-color. Status **never color-only** — always color + icon (check/x) + text. +- **Touch targets:** ≥48×48dp; primary buttons & chips 56dp; nav items 80dp; ≥8dp spacing. +- **Font scaling:** all type in `sp`; tested to 200% without truncation; the big Fraunces target word caps growth and reflows. +- **Reduced motion:** honor `Settings.Global.TRANSITION_ANIMATION_SCALE == 0` and an in-app toggle → fades, static badges, loops/parallax off. +- **Screen reader:** content descriptions on all icon-buttons; "Exercise 3 of 8" announced; Slovak spans tagged `lang="sk"` for correct TalkBack pronunciation; result banners use `liveRegion`. +- **Audio-independent:** every listening exercise has a transcript toggle; captions on spoken content; haptics on correct/wrong. + +--- + +## 11. Technical Architecture + +**Single Gradle module (`:app`)** with strict internal package layering (multi-module overhead isn't justified for a solo build). Layering enforced by packages + lint convention. + +``` +com.slovko +├─ SlovkoApp.kt // @HiltAndroidApp +├─ MainActivity.kt // single-Activity, setContent { SlovkoApp() } +├─ core/{designsystem, common, notification} +├─ data/{db, datastore, seed, audio, ai, repository} +├─ domain/{model, repository, usecase} +├─ ui/{navigation, home, lesson, lessoncomplete, practice, chat, chathub, +│ profile, onboarding, achievements, leaderboard, settings} +└─ work/ // WorkManager workers + scheduling + boot receiver +``` + +**Dependency rule:** `ui → domain → data` at the interface level; `data` implements `domain.repository`. UI never touches Room/DataStore directly. Domain has zero Android dependencies (pure Kotlin/coroutines) → JVM-unit-testable use cases (FSRS, grading, XP, streak, league). + +**DI: Hilt (KSP).** `@HiltViewModel`, `hiltViewModel()` in Compose, `@HiltWorker` via `androidx.hilt:hilt-work`. The graph (Room + 11 DAOs + DataStore + repos + injected workers + TTS/audio singletons + pluggable AI partner) justifies Hilt over manual DI. + +**State management:** each screen has a ViewModel exposing `val uiState: StateFlow` (sealed interface). Events go up via plain functions (`onAnswerSelected`, `onSubmit`). Repositories return `Flow`; ViewModels `combine/map` then `.stateIn(viewModelScope, WhileSubscribed(5_000), Loading)`. + +```kotlin +sealed interface LessonUiState { + data object Loading : LessonUiState + data class Active(val exercises: List, val index: Int, + val focus: FocusState, val progress: Float, + val feedback: Feedback? = null) : LessonUiState + data class Completed(val xpEarned: Int, val accuracy: Float, val durationMs: Long, + val streakDelta: Int, val newAchievements: List) : LessonUiState + data class Error(val message: String) : LessonUiState +} +``` + +**Audio:** abstract behind `PronunciationPlayer`. Default = Android `TextToSpeech` (sk-SK, offline once voice installed, zero APK weight). High-frequency phrases ship as bundled native-speaker MP3s (`assets/audio/.mp3`) played via Media3/ExoPlayer; missing key → TTS fallback. + +**Navigation:** single `NavHost`, `@Serializable` route types — Onboarding, Home, Lesson(lessonId), LessonComplete(lessonId), Practice, ChatHub, Chat(scenarioId), Profile, Achievements, Leaderboard, Settings. Bottom bar: **Learn · Practice · Chat · Leaderboard · Profile** (5 items). Lesson/Chat are full-screen pushes. + +**Offline-first guarantees:** Room is the single source of truth; UI renders only from DB `Flow`s; TTS + MP3s are local; the only network touchpoint is the optional AI partner; WorkManager reads Room locally with no server. + +--- + +## 12. Data Layer (Room schema) + +Entities live in `data.db.entity`; domain models are separate (no Room annotations). Seeded content uses stable **String slug** PKs (clean re-seed across versions); runtime rows use autogen `Long`. Read POJOs via `@Relation`/`@Transaction` (`SkillWithLessons`, `LessonWithExercises`). DAOs expose `Flow<…>` for reads; `suspend` for writes. + +**Content entities:** `SkillEntity(id, unitId, title, description, iconKey, colorKey, orderIndex, cefrLevel)` → `LessonEntity(id, skillId, title, orderIndex, type, xpReward)` → `ExerciseEntity(id, lessonId, orderIndex, type, promptSk?, promptEn?, answer, acceptableJson, choicesJson?, audioKey?, vocabCardId?, hint?, caseTag?, aspectTag?, register)`. `VocabCardEntity(id, sk, en, partOfSpeech, ipa?, exampleSk?, exampleEn?, gender?, audioKey?, register, frequencyRank)`. `ChatScenarioEntity(id, title, description, cefrLevel, systemPromptSk, starterLineSk, targetVocabJson, iconKey, locked)`. `PhraseEntity(id, sk, en, register, note?, vocabCardId?)`. + +**SRS state — FSRS fields** (supersedes SM-2): +```kotlin +@Entity(tableName = "srs_state", + foreignKeys = [ForeignKey(VocabCardEntity::class, ["id"], ["cardId"], onDelete = CASCADE)], + indices = [Index("cardId", unique = true), Index("due"), Index("state")]) +data class SrsStateEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val cardId: String, + val state: Int = 0, // 0 New 1 Learning 2 Review 3 Relearning + val stability: Float = 0f, // FSRS S + val difficulty: Float = 0f, // FSRS D + val due: Long, // epoch millis; drives "what's due today" + val lastReview: Long? = null, + val reps: Int = 0, + val lapses: Int = 0, + val direction: Int = 0 // 0 SK→EN recognition, 1 EN→SK production +) +``` + +**Progress/gamification entities:** `UserProfileEntity(id=1 singleton, displayName, level, totalXp, gems, crowns, createdAt, timezoneId, avatarKey)`; `DailyGoalEntity(date ISO, goalXp, earnedXp, lessonsCompleted, met)`; `StreakLogEntity(date ISO, practiced, frozen)` (current streak = derived query over consecutive practiced/frozen days ending today); `AchievementEntity(id, title, description, iconKey, threshold, progress, unlockedAt?, tier)`; `LeagueWeekEntity(weekId, tier, userXp, botsJson, startTs, endTs)`; `QuestEntity(id, dateAssigned, title, target, progress, rewardGems, completed)`; `ChatMessageEntity(id autogen, scenarioId, role, textSk, textEnGloss?, createdAt)`; `SessionLogEntity(id autogen, startTs, durationMs, xpEarned)` (analytics for smart timing). + +**Relationships:** `Skill 1—* Lesson 1—* Exercise`; `Exercise *—1 VocabCard` (optional); `VocabCard 1—* SrsState` (per direction); `ChatScenario 1—* ChatMessage`. Migrations are explicit (no destructive fallback in release). + +--- + +## 13. Content Shipping & Seeding + +Content ships as **bundled JSON in `app/src/main/assets/curriculum/`**, split per skill + a manifest. On first launch and on version bump, `ContentSeeder` parses with kotlinx-serialization and bulk-inserts inside one Room transaction. Seed version tracked in DataStore (`seed_version`); if bundled `version > stored`, re-seed **content tables only** with `OnConflictStrategy.REPLACE` on stable slug PKs — user/SRS/streak/profile tables are never touched, so progress survives content updates. + +`manifest.json`: `{ "version": 1, "skillFiles": [...], "scenarioFile": "scenarios.json", "phrasebookFile": "phrasebook.json" }`. Per-skill JSON nests `skill`, `vocab[]`, `lessons[].exercises[]`. See [contentPlan] for the full schema. + +--- + +## 14. Notifications & Retention + +Single `NotificationScheduler` reads DataStore + Room and (re)enqueues all work; called after every session, on app open, on settings change, on `BOOT_COMPLETED`, and on `TIMEZONE_CHANGED`. All work idempotent via unique names. + +**Workers** +1. **`DailyReminderWorker`** — self-rescheduling one-time work (hits precise local time, immune to periodic drift). If user already practiced today (check `SessionLogEntity`), suppress; always re-enqueue for tomorrow. +2. **`SmartTimingWorker`** — periodic ~03:00; reads last ~14 days of sessions, picks the **modal practice hour** (needs ≥5 sessions and ≥40% confidence to override the onboarding choice), schedules the reminder 30 min ahead of the habit window; clamps to 07:00–22:00 and never into quiet hours. +3. **`StreakRiskWorker`** — one-time, armed for 20:30 (or quietHoursStart−1h). Fires only if `streak.current > 0` and not practiced today; silent otherwise. Own channel; gated by `streakRemindersOn`. +4. **`ReviewDueWorker`** — SRS-driven; schedules at the next "≥10 cards due" timestamp, clamped outside quiet hours, ≤1/day; deep-links into the review queue; re-evaluated after each session. +5. **`ReengageWorker`** — daily-checking chain for lapsed users (no session 24h+). Escalates *down* in frequency; cancelled instantly on next open. + +**Channels** (created once, `NotificationChannelGroup "Slovko"`): `daily_practice` (DEFAULT), `streak` (HIGH, vibrate), `reviews` (LOW, silent), `reengage` (DEFAULT), `achievements` (DEFAULT). In-app toggles gate enqueue/post; OS channels own sound/visibility — Settings *links out* to system channel settings rather than faking toggles. + +**Copy** via `NotificationCopyProvider`: rotate variants (store `lastCopyId`, never repeat back-to-back); always include a real, useful Slovak phrase (so even an ignored notification teaches); never shame words; cap exclamation marks. 10 seed copies across Daily/Streak/Review/Re-engage. + +**POST_NOTIFICATIONS (Android 13+):** never prompt on first launch. Soft pre-prompt on the first-lesson celebration ("Want a gentle daily nudge? You pick the time." Yes/Maybe later); only on Yes fire the system request. If permanently denied, deep-link to app notification settings. Pre-API-33: implicitly granted. Re-surface the pre-prompt at most once more ~day 3 if still active. + +**Re-engagement ladder:** Day 1 (light, one word + audio) → Day 3 (progress hook to in-progress unit) → Day 7 (low-friction comeback challenge + freeze) → Day 14 (final soft touch, then **mute the ladder** until return). Never two re-engage notifs in 24h; respect quiet hours; cancel all pending on next session. + +**Quiet hours** enforced app-side (never schedule into them) as belt-and-suspenders over OS DND. + +**Reboot:** `BootReceiver` (`ACTION_BOOT_COMPLETED`, `RECEIVE_BOOT_COMPLETED`) calls `NotificationScheduler.rescheduleAll()` so self-rescheduling chains survive a device off at fire time. + +--- + +## 15. Widget & Quick Actions + +**Glance widget** (`GlanceAppWidget`, fully offline, reads Room/DataStore): +- **Small 2×1:** 🔥 streak + tap → `slovko://practice`. +- **Medium 4×2:** streak/flame, "X cards due", word-of-the-day + translation, prominent "Practice now"; tapping the word plays TTS via pending intent. +- Updates after each session via `GlanceAppWidgetManager.update()` and on the smart-timing tick. + +**Quick actions:** `ShortcutManager` long-press shortcuts (Practice now / Review due / Word of the day). Daily reminder carries `"Practice"` (deep link) + `"Snooze 1h"` (re-enqueues that one reminder +1h) actions. + +--- + +## 16. Build, Versions & CI + +- **minSdk 26, targetSdk 35, compileSdk 35.** Gradle Kotlin DSL + version catalog. KSP for Room + Hilt. Compose Compiler via `org.jetbrains.kotlin.plugin.compose`. + +**Known-compatible pairing (Gradle 8.14, JDK 21):** AGP 8.7.3 · Kotlin 2.0.21 · KSP 2.0.21-1.0.28 · Hilt 2.52 · Compose BOM 2024.12.01 · Room 2.6.1 · Nav 2.8.5 · DataStore 1.1.1 · WorkManager 2.10.0 + hilt-work 1.2.0 · Coroutines 1.9.0 · kotlinx-serialization-json 1.7.3 · Media3 1.5.1 · Turbine 1.2.0. `jvmToolchain(21)`. + +**Testing:** JUnit4, Turbine, Room in-memory, Compose UI test, Robolectric. Domain (FSRS, grading, XP, streak, league bots) is pure-Kotlin JVM-tested. + +**gradle.properties:** `org.gradle.jvmargs=-Xmx4g`, `org.gradle.caching=true`. + +**CI** (`.github/workflows/android-ci.yml`): checkout → setup-java 21 (temurin) → setup-gradle → `chmod +x gradlew` → `testDebugUnitTest` → `assembleDebug` → upload `app-debug.apk`. + +--- + +## 17. Resolved Cross-Section Decisions + +These conflicts between source sections were resolved as follows (canonical): + +1. **SRS algorithm — FSRS wins over SM-2.** The pedagogy section's FSRS is authoritative; the arch draft's SM-2 `SrsStateEntity` (`easeFactor/intervalDays/repetitions`) is **replaced** by the FSRS schema in [§12](#12-data-layer-room-schema) (`state/stability/difficulty/due/reps/lapses/direction`). FSRS is implemented as a pure-Kotlin `FsrsScheduler` in domain. +2. **Hearts vs Focus — Focus is the default; Hearts is opt-in Challenge Mode.** The gamification "no-lockout" Focus model is the default learning experience; the visual section's hearts UI is retained but scoped to Challenge Mode. `LessonUiState.Active` carries a `FocusState` (not mandatory hearts). +3. **Bottom nav — 5 items.** Visual's 5-item bar (Learn · Practice · Chat · Leaderboard · Profile) supersedes arch's 4-item bar, since simulated leagues are a core retention feature. +4. **Card direction is first-class.** `(lemma, sense, direction)` granularity from pedagogy is encoded as `SrsStateEntity.direction` (recognition graduates before production unlocks). +5. **Exercise enum is the union** of both sections, adding `FILL_CASE`, `ASPECT_CHOICE`, `LISTEN_CHOOSE`, `DIALOGUE_FILL` to the arch baseline, plus `caseTag`/`aspectTag`/`register` columns on `ExerciseEntity`. +6. **Audio:** TTS default + per-card MP3 override behind `PronunciationPlayer` (both sections agree; abstraction made explicit). +7. **Daily goal tiers:** the gamification XP tiers (30/60/120/250) are canonical; onboarding presents them as Casual/Regular/Serious/Intense. + + +--- + +# Appendix A — Content Plan + +## Curriculum JSON schema (bundled in app/src/main/assets/curriculum/) + +### manifest.json +```json +{ + "version": 1, + "skillFiles": [ + "unit0-zvuky.json","unit1-ahoj.json","unit2-ja-a-ty.json","unit3-rodina.json", + "unit4-jedlo-kava.json","unit5-v-meste.json","unit6-cas-plany.json", + "unit7-kazdy-den.json","unit8-nakupovanie.json","unit9-pocasie-pocity.json", + "unit10-praca-skola.json","unit11-cestovanie.json","unit12-piseme-si.json" + ], + "scenarioFile": "scenarios.json", + "phrasebookFile": "phrasebook.json" +} +``` + +### Per-unit skill file schema (each file = one Unit; contains multiple skills) +```json +{ + "unit": { "id": "u1", "name": "Ahoj!", "cefr": "A1", "order": 1 }, + "skills": [ + { + "id": "greetings", // stable slug PK + "unitId": "u1", + "title": "Greetings & Small Talk", + "description": "Say hi like a local.", + "iconKey": "wave", // -> designsystem vector + "colorKey": "teal", + "orderIndex": 0, + "cefrLevel": "A1", + "exercisePool": { "MCQ": 3, "LISTEN_CHOOSE": 2, "WORD_BANK": 2, "SPEAK": 1 }, + "vocab": [ + { + "id": "ahoj", "sk": "ahoj", "en": "hi / bye (informal)", + "partOfSpeech": "phrase", "ipa": "ˈaɦoj", + "exampleSk": "Ahoj, ako sa máš?", "exampleEn": "Hi, how are you?", + "gender": null, "audioKey": "ahoj", + "register": "informal", "frequencyRank": 3 + } + ], + "lessons": [ + { + "id": "greetings-1", "title": "First hellos", "orderIndex": 0, + "type": "teach", // teach | practice | checkpoint + "xpReward": 15, + "exercises": [ + { + "id": "greetings-1-e1", "orderIndex": 0, "type": "MCQ", + "promptEn": "How do you informally say 'hi'?", "promptSk": null, + "answer": "ahoj", "acceptable": ["ahoj"], + "choices": ["ahoj","dovidenia","prosím","ďakujem"], + "audioKey": "ahoj", "vocabCardId": "ahoj", + "hint": "Same word for hi and bye.", + "register": "informal", "caseTag": null, "aspectTag": null + }, + { + "id": "greetings-1-e2", "orderIndex": 1, "type": "LISTEN_TYPE", + "promptSk": "Ako sa máš?", "promptEn": null, + "answer": "Ako sa máš?", "acceptable": ["ako sa mas","ako sa máš"], + "choices": null, "audioKey": "ako_sa_mas", + "vocabCardId": null, "hint": "'How are you?'", + "register": "informal", "caseTag": null, "aspectTag": null + } + ] + } + ] + } + ] +} +``` + +**Field rules / enums** +- `type` (exercise): MCQ | TRANSLATE_SK_EN | TRANSLATE_EN_SK | LISTEN_CHOOSE | LISTEN_TYPE | WORD_BANK | SPEAK | MATCH_PAIRS | FILL_CASE | ASPECT_CHOICE | DIALOGUE_FILL. +- `register`: informal | neutral | formal. +- `caseTag`: NOM | ACC | LOC | INS | GEN | DAT | null. +- `aspectTag`: IMPF | PERF | null. +- `acceptable[]`: lowercased, diacritic- and word-order-tolerant accepted variants. `LISTEN_TYPE` keeps a diacritic-correct variant for strict grading where taught. +- `choices` required for MCQ/WORD_BANK/MATCH_PAIRS, else null. +- `audioKey`: maps to assets/audio/.mp3; missing → TTS. +- A card is SRS-eligible only once a `vocabCardId`-linked production exercise is answered correctly (the "born after produced" rule is enforced at runtime, not in JSON). + +### scenarios.json schema (Chat track) +```json +{ "scenarios": [ + { "id":"ordering-coffee","title":"Coffee with a friend","description":"Order and chat at a café.", + "cefrLevel":"A1","systemPromptSk":"Si kamarát v kaviarni...","starterLineSk":"Čau! Čo si dáš?", + "targetVocab":["kava","dam-si","prosim"],"iconKey":"coffee","locked":false, + "turns":[ {"role":"partner","sk":"Čau! Čo si dáš?","enGloss":"Hey! What'll you have?", + "replies":[{"sk":"Dám si kávu, prosím.","enGloss":"I'll have a coffee, please.","natural":true}, + {"sk":"Chcem čaj.","enGloss":"I want tea.","natural":false}]} ] } ] } +``` + +### phrasebook.json schema +```json +{ "phrases": [ + { "id":"caw","sk":"čau","en":"hi / bye","register":"informal","note":"most casual","vocabCardId":"caw" }, + { "id":"daj-vediet","sk":"daj vedieť","en":"let me know","register":"informal","note":null,"vocabCardId":null } +] } +``` + +## Units / Lessons / Exercises to author (counts) + +Author all 13 unit files. Per skill: 4–6 lessons; per lesson: 8–12 exercises drawn from the skill's weighted `exercisePool`. Recognition exercises (SK→EN, LISTEN_CHOOSE, MCQ) lead each skill; production (WORD_BANK, TRANSLATE_EN_SK, SPEAK) follow; grammar skills add FILL_CASE / ASPECT_CHOICE. + +| Unit file | Skills (3–5 each) | New vocab cards (approx) | Special exercise focus | +|---|---|---|---| +| unit0-zvuky | Alphabet & diacritics; The hard ones (ä,ô,ľ,ď,ť,ň,dz/dž); Stress on syll.1; Read-aloud | 0 SRS (phoneme drills) | LISTEN_TYPE, SPEAK, minimal-pair MCQ; **no SRS** | +| unit1-ahoj | Informal vs formal greetings; Ako sa máš/máte; Volám sa; Ďakujem/prosím/prepáč; Texting openers | ~25 | DIALOGUE_FILL (vykať/tykať toggle), MCQ, SPEAK | +| unit2-ja-a-ty | Pronouns; byť present; Som z…/nationalities; Hovoríš po…?; Numbers 0–20 | ~35 | WORD_BANK (byť agreement), LISTEN_TYPE | +| unit3-rodina | Family nouns + diminutives; Possessives môj/moja/moje; Mám… (ACC intro); Age; Pets | ~40 | FILL_CASE (ACC), MATCH_PAIRS (diminutive↔base) | +| unit4-jedlo-kava | Foods/drinks; Chcem/Dám si; Máte…?; Mám rád/rada; café & krčma | ~45 | FILL_CASE (ACC of food), DIALOGUE_FILL | +| unit5-v-meste | Places; Kde je…?; Directions; Locative (v/na); Transport (instrumental); Idem do/na | ~45 | FILL_CASE (LOC, INS) | +| unit6-cas-plany | Clock & o koľkej; Days/months; dnes/zajtra/víkend; Making plans; **Aspect intro** | ~40 | ASPECT_CHOICE (robiť/urobiť), texting | +| unit7-kazdy-den | Routine verbs; Reflexives (umývam sa); Frequency adverbs; Modals (musím/chcem/môžem) | ~45 | ASPECT_CHOICE drilled, WORD_BANK | +| unit8-nakupovanie | Koľko to stojí; Euros/cents; Clothing/sizes; Zaplatiť kartou; Genitive of quantity | ~40 | FILL_CASE (GEN of quantity) | +| unit9-pocasie-pocity | Weather; Je mi… (dative of state); Emotions; Small talk; Reactions | ~40 | FILL_CASE (DAT), DIALOGUE_FILL | +| unit10-praca-skola | Jobs/studies; Čím si/Kde robíš; Workplace vocab; Instrumental of profession | ~35 | FILL_CASE (INS of profession) | +| unit11-cestovanie | Train/bus/booking; Accommodation; Asking help; Emergencies; **Past tense intro** | ~45 | WORD_BANK (past perfective), DIALOGUE_FILL | +| unit12-piseme-si | Capstone: real WhatsApp conversations pulling all units together (north-star Kamarát unit) | ~30 review-heavy | DIALOGUE_FILL, TRANSLATE_EN_SK, SPEAK | + +**Totals (target):** 13 units, ~50 skills, ~250 lessons (avg 5/skill), ~2,500 exercises (avg 10/lesson), ~500 unique vocab cards (aligns with the "Slovník" Bronze 500-word badge). + +**scenarios.json:** author 8 scenarios — Coffee with a friend, Texting plans, Running late, Weekend recap, At the pub, Asking a favor, Meeting the family, Group-chat reactions. Each 6–12 turns, ≥2 reply options per turn with a `natural` flag. + +**phrasebook.json:** author ~40 modern texting entries (čau, čo robíš?, ako šlo?, si tam?, daj vedieť, dohodnuté, hej/jasné/v pohode, no nevadí, vďaka/dik, meškám trochu, sorry/prepáč, vidíme sa, maj sa/pa, haha/xd, no, plus diacritic-dropping and vowel-stretching examples). + diff --git a/slovko/app/build.gradle.kts b/slovko/app/build.gradle.kts new file mode 100644 index 0000000..0697f77 --- /dev/null +++ b/slovko/app/build.gradle.kts @@ -0,0 +1,91 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.slovko" + compileSdk = 35 + + defaultConfig { + applicationId = "com.slovko" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { useSupportLibrary = true } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + debug { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + debugImplementation(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.navigation.compose) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.work.runtime.ktx) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + testImplementation(libs.junit) + testImplementation(libs.turbine) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/slovko/app/proguard-rules.pro b/slovko/app/proguard-rules.pro new file mode 100644 index 0000000..23d6b2c --- /dev/null +++ b/slovko/app/proguard-rules.pro @@ -0,0 +1,18 @@ +# kotlinx-serialization +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.** +-keepclassmembers class **$$serializer { *; } +-keepclasseswithmembers class com.slovko.** { + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class com.slovko.**$$serializer { *; } +-keepclassmembers class com.slovko.** { + *** Companion; +} + +# Room +-keep class * extends androidx.room.RoomDatabase { (); } +-dontwarn androidx.room.paging.** + +# Hilt / Dagger generated +-dontwarn com.google.errorprone.annotations.** diff --git a/slovko/app/src/main/AndroidManifest.xml b/slovko/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0b2a54d --- /dev/null +++ b/slovko/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/slovko/app/src/main/java/com/slovko/MainActivity.kt b/slovko/app/src/main/java/com/slovko/MainActivity.kt new file mode 100644 index 0000000..6485e96 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/MainActivity.kt @@ -0,0 +1,48 @@ +package com.slovko + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.SlovkoTheme +import com.slovko.domain.model.ThemeMode +import com.slovko.ui.RootViewModel +import com.slovko.ui.navigation.SlovkoApp +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + private val rootViewModel: RootViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + val deepLinkHost = intent?.data?.host + + setContent { + val settings by rootViewModel.settings.collectAsStateWithLifecycle() + val s = settings + val dark = when (s?.themeMode ?: ThemeMode.SYSTEM) { + ThemeMode.DARK -> true + ThemeMode.LIGHT -> false + ThemeMode.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme() + } + SlovkoTheme( + darkTheme = dark, + dynamicColor = s?.dynamicColor ?: true, + reducedMotion = s?.reducedMotion ?: false, + ) { + SlovkoApp( + onboarded = s?.onboarded ?: false, + settingsLoaded = s != null, + deepLinkHost = deepLinkHost, + ) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/SlovkoApp.kt b/slovko/app/src/main/java/com/slovko/SlovkoApp.kt new file mode 100644 index 0000000..de74f08 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/SlovkoApp.kt @@ -0,0 +1,24 @@ +package com.slovko + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.slovko.core.notification.NotificationChannels +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class SlovkoApp : Application(), Configuration.Provider { + + @Inject lateinit var workerFactory: HiltWorkerFactory + + override fun onCreate() { + super.onCreate() + NotificationChannels.createAll(this) + } + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() +} diff --git a/slovko/app/src/main/java/com/slovko/core/common/AppClock.kt b/slovko/app/src/main/java/com/slovko/core/common/AppClock.kt new file mode 100644 index 0000000..7599df4 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/common/AppClock.kt @@ -0,0 +1,17 @@ +package com.slovko.core.common + +import java.time.LocalDate +import java.time.ZoneId + +/** Injectable clock so time-dependent logic is unit-testable. */ +interface AppClock { + fun nowMillis(): Long + fun zone(): ZoneId + fun today(): LocalDate +} + +class SystemClock : AppClock { + override fun nowMillis(): Long = System.currentTimeMillis() + override fun zone(): ZoneId = ZoneId.systemDefault() + override fun today(): LocalDate = LocalDate.now(zone()) +} diff --git a/slovko/app/src/main/java/com/slovko/core/common/Dispatchers.kt b/slovko/app/src/main/java/com/slovko/core/common/Dispatchers.kt new file mode 100644 index 0000000..9dbca1c --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/common/Dispatchers.kt @@ -0,0 +1,11 @@ +package com.slovko.core.common + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher diff --git a/slovko/app/src/main/java/com/slovko/core/common/TextNormalizer.kt b/slovko/app/src/main/java/com/slovko/core/common/TextNormalizer.kt new file mode 100644 index 0000000..304369f --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/common/TextNormalizer.kt @@ -0,0 +1,29 @@ +package com.slovko.core.common + +import java.text.Normalizer +import java.util.Locale + +/** Diacritic-insensitive, punctuation/whitespace-tolerant normalization for grading. */ +object TextNormalizer { + + private val combiningMarks = "\\p{InCombiningDiacriticalMarks}+".toRegex() + private val nonWord = "[^a-z0-9 ]".toRegex() + private val spaces = "\\s+".toRegex() + + /** Lowercase, strip diacritics & punctuation, collapse spaces. */ + fun normalize(input: String): String { + val lowered = input.lowercase(Locale.ROOT).trim() + val decomposed = Normalizer.normalize(lowered, Normalizer.Form.NFD) + val noMarks = combiningMarks.replace(decomposed, "") + return spaces.replace(nonWord.replace(noMarks, " "), " ").trim() + } + + /** Like [normalize] but keeps diacritics (for strict listen-and-type grading). */ + fun normalizeStrict(input: String): String = + spaces.replace(input.lowercase(Locale.ROOT).trim(), " ") + + /** Order-insensitive token comparison (word-bank tolerance). */ + fun sameWords(a: String, b: String): Boolean = + normalize(a).split(" ").filter { it.isNotEmpty() }.sorted() == + normalize(b).split(" ").filter { it.isNotEmpty() }.sorted() +} diff --git a/slovko/app/src/main/java/com/slovko/core/designsystem/Color.kt b/slovko/app/src/main/java/com/slovko/core/designsystem/Color.kt new file mode 100644 index 0000000..1a61f90 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/designsystem/Color.kt @@ -0,0 +1,76 @@ +package com.slovko.core.designsystem + +import androidx.compose.ui.graphics.Color + +/** + * Slovko palette — warm, earthy-premium, distinctly Central-European. + * Folk red + Tatra pine + streak gold, over cream surfaces. See DESIGN.md §8. + */ + +// ---- Light ---- +val FolkRed = Color(0xFFC0392B) +val FolkRedDark = Color(0xFF9B2C20) // button "ledge" +val OnFolkRed = Color(0xFFFFFFFF) +val FolkRedContainer = Color(0xFFFFDAD4) +val OnFolkRedContainer = Color(0xFF410001) + +val Pine = Color(0xFF2E6E4E) +val OnPine = Color(0xFFFFFFFF) +val PineContainer = Color(0xFFB4F1C8) +val OnPineContainer = Color(0xFF00210F) + +val SkySlate = Color(0xFF3E6B8C) +val OnSkySlate = Color(0xFFFFFFFF) +val SkySlateContainer = Color(0xFFCDE5FF) +val OnSkySlateContainer = Color(0xFF001E30) + +val Gold = Color(0xFFF2A900) +val OnGold = Color(0xFF3A2A00) +val GoldContainer = Color(0xFFFFE08A) + +val SuccessGreen = Color(0xFF2E7D32) +val ErrorRed = Color(0xFFBA1A1A) + +val BackgroundLight = Color(0xFFFFFBF5) +val SurfaceLight = Color(0xFFFFFBF5) +val SurfaceContainerLight = Color(0xFFF7EFE3) +val SurfaceContainerHighLight = Color(0xFFF1E6D6) +val OnSurfaceLight = Color(0xFF231A14) +val OnSurfaceVariantLight = Color(0xFF5A5046) +val OutlineLight = Color(0xFF857466) + +val CicmanyLineLight = Color(0xFFE6D2C0) + +// ---- Dark (Tatra night) ---- +val FolkRedDarkScheme = Color(0xFFFFB4A8) +val OnFolkRedDarkScheme = Color(0xFF690002) +val FolkRedContainerDark = Color(0xFF93000A) +val OnFolkRedContainerDark = Color(0xFFFFDAD4) + +val PineDark = Color(0xFF99D4AD) +val OnPineDark = Color(0xFF00391E) +val PineContainerDark = Color(0xFF0F5132) +val OnPineContainerDark = Color(0xFFB4F1C8) + +val SkySlateDark = Color(0xFFA6CBEF) +val OnSkySlateDark = Color(0xFF003351) +val SkySlateContainerDark = Color(0xFF1F4A6A) + +val GoldDark = Color(0xFFFFC93C) +val OnGoldDark = Color(0xFF3A2A00) + +val SuccessGreenDark = Color(0xFF7FCB83) +val ErrorRedDark = Color(0xFFFFB4AB) + +val BackgroundDark = Color(0xFF1A140F) +val SurfaceDark = Color(0xFF1A140F) +val SurfaceContainerDark = Color(0xFF271E18) +val SurfaceContainerHighDark = Color(0xFF332821) +val OnSurfaceDark = Color(0xFFEFE0D5) +val OnSurfaceVariantDark = Color(0xFFD6C3B5) +val OutlineDark = Color(0xFF9F8D7E) + +val CicmanyLineDark = Color(0xFF3D3026) + +// Frozen-streak ice accent (shared) +val IceBlue = Color(0xFF7FB3D5) diff --git a/slovko/app/src/main/java/com/slovko/core/designsystem/IconKeys.kt b/slovko/app/src/main/java/com/slovko/core/designsystem/IconKeys.kt new file mode 100644 index 0000000..19cf227 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/designsystem/IconKeys.kt @@ -0,0 +1,15 @@ +package com.slovko.core.designsystem + +/** Maps content/skill/achievement icon keys to emoji glyphs (no asset pipeline needed). */ +object IconKeys { + private val map = mapOf( + "wave" to "👋", "coffee" to "☕", "chat" to "💬", "star" to "⭐", "home" to "🏠", + "family" to "👨‍👩‍👧", "food" to "🍽️", "city" to "🏙️", "clock" to "🕐", "calendar" to "📅", + "shop" to "🛍️", "weather" to "🌤️", "work" to "💼", "travel" to "🧳", "sound" to "🔊", + "book" to "📖", "person" to "🧑", "heart" to "❤️", "flame" to "🔥", "gem" to "💎", + "trophy" to "🏆", "target" to "🎯", "mic" to "🎤", "ear" to "👂", "run" to "🏃", + "friends" to "🤝", "up" to "⬆️", "moon" to "🌙", "sunrise" to "🌅", "spark" to "✨", + "number" to "🔢", "pet" to "🐶", "money" to "💶", "pub" to "🍺", "phone" to "📱", + ) + operator fun get(key: String?): String = map[key] ?: "⭐" +} diff --git a/slovko/app/src/main/java/com/slovko/core/designsystem/Motion.kt b/slovko/app/src/main/java/com/slovko/core/designsystem/Motion.kt new file mode 100644 index 0000000..b92de2e --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/designsystem/Motion.kt @@ -0,0 +1,35 @@ +package com.slovko.core.designsystem + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable + +/** Motion language from DESIGN.md §8 — springy by default, gated by Reduced Motion. */ +object Motion { + const val PRESS_SCALE = 0.96f + const val PRESS_DURATION_MS = 90 + + fun defaultSpring(): FiniteAnimationSpec = + spring(dampingRatio = 0.7f, stiffness = 380f) + + fun bouncy(): FiniteAnimationSpec = + spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium) + + fun fade(durationMs: Int = 120): FiniteAnimationSpec = tween(durationMs) +} + +/** Picks a springy spec normally, a quick fade when Reduced Motion is on. */ +@Composable +fun motionSpec(reduced: Boolean): FiniteAnimationSpec = + if (reduced) Motion.fade() else Motion.defaultSpring() + +/** End-of-lesson celebration timing (DESIGN.md §7). */ +object CelebrationTiming { + const val CARD_IN_MS = 280 + const val XP_COUNT_UP_MS = 900 + const val ROW_STAGGER_MS = 140 + const val STREAK_PANEL_MS = 500 + const val TOTAL_CEILING_MS = 4000 +} diff --git a/slovko/app/src/main/java/com/slovko/core/designsystem/Theme.kt b/slovko/app/src/main/java/com/slovko/core/designsystem/Theme.kt new file mode 100644 index 0000000..5410b5b --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/designsystem/Theme.kt @@ -0,0 +1,145 @@ +package com.slovko.core.designsystem + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.compose.material3.MaterialTheme + +private val LightColors = lightColorScheme( + primary = FolkRed, onPrimary = OnFolkRed, + primaryContainer = FolkRedContainer, onPrimaryContainer = OnFolkRedContainer, + secondary = Pine, onSecondary = OnPine, + secondaryContainer = PineContainer, onSecondaryContainer = OnPineContainer, + tertiary = SkySlate, onTertiary = OnSkySlate, + tertiaryContainer = SkySlateContainer, onTertiaryContainer = OnSkySlateContainer, + error = ErrorRed, onError = Color.White, + background = BackgroundLight, onBackground = OnSurfaceLight, + surface = SurfaceLight, onSurface = OnSurfaceLight, + surfaceVariant = SurfaceContainerLight, onSurfaceVariant = OnSurfaceVariantLight, + surfaceContainer = SurfaceContainerLight, + surfaceContainerHigh = SurfaceContainerHighLight, + outline = OutlineLight, +) + +private val DarkColors = darkColorScheme( + primary = FolkRedDarkScheme, onPrimary = OnFolkRedDarkScheme, + primaryContainer = FolkRedContainerDark, onPrimaryContainer = OnFolkRedContainerDark, + secondary = PineDark, onSecondary = OnPineDark, + secondaryContainer = PineContainerDark, onSecondaryContainer = OnPineContainerDark, + tertiary = SkySlateDark, onTertiary = OnSkySlateDark, + tertiaryContainer = SkySlateContainerDark, onTertiaryContainer = Color.White, + error = ErrorRedDark, onError = Color(0xFF690005), + background = BackgroundDark, onBackground = OnSurfaceDark, + surface = SurfaceDark, onSurface = OnSurfaceDark, + surfaceVariant = SurfaceContainerDark, onSurfaceVariant = OnSurfaceVariantDark, + surfaceContainer = SurfaceContainerDark, + surfaceContainerHigh = SurfaceContainerHighDark, + outline = OutlineDark, +) + +val SlovkoShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(20.dp), + large = RoundedCornerShape(28.dp), + extraLarge = RoundedCornerShape(36.dp), +) + +/** 4dp spacing grid (DESIGN.md §8). */ +data class Spacing( + val xxs: Dp = 4.dp, + val xs: Dp = 8.dp, + val sm: Dp = 12.dp, + val md: Dp = 16.dp, + val lg: Dp = 24.dp, + val xl: Dp = 32.dp, + val xxl: Dp = 48.dp, + val xxxl: Dp = 64.dp, + val screenEdge: Dp = 20.dp, +) + +val LocalSpacing = staticCompositionLocalOf { Spacing() } +val LocalReducedMotion = staticCompositionLocalOf { false } + +/** Extra semantic colors not in the Material scheme (gold, success, folk lines). */ +data class SlovkoColors( + val gold: Color, + val onGold: Color, + val success: Color, + val cicmanyLine: Color, + val ice: Color, + val buttonLedge: Color, +) + +val LocalSlovkoColors = staticCompositionLocalOf { + SlovkoColors(Gold, OnGold, SuccessGreen, CicmanyLineLight, IceBlue, FolkRedDark) +} + +/** Convenience accessors: MaterialTheme.spacing / .slovkoColors */ +val MaterialTheme.spacing: Spacing + @Composable get() = LocalSpacing.current +val MaterialTheme.slovkoColors: SlovkoColors + @Composable get() = LocalSlovkoColors.current + +@Composable +fun SlovkoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + reducedMotion: Boolean = false, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + darkTheme -> DarkColors + else -> LightColors + } + + val slovkoColors = if (darkTheme) { + SlovkoColors(GoldDark, OnGoldDark, SuccessGreenDark, CicmanyLineDark, IceBlue, FolkRedDark) + } else { + SlovkoColors(Gold, OnGold, SuccessGreen, CicmanyLineLight, IceBlue, FolkRedDark) + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = + colorScheme.background.luminance() > 0.5f + } + } + + CompositionLocalProvider( + LocalSpacing provides Spacing(), + LocalReducedMotion provides reducedMotion, + LocalSlovkoColors provides slovkoColors, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = SlovkoTypography, + shapes = SlovkoShapes, + content = content, + ) + } +} diff --git a/slovko/app/src/main/java/com/slovko/core/designsystem/Type.kt b/slovko/app/src/main/java/com/slovko/core/designsystem/Type.kt new file mode 100644 index 0000000..fb02472 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/designsystem/Type.kt @@ -0,0 +1,59 @@ +package com.slovko.core.designsystem + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * Type scale from DESIGN.md §8. + * Display/headlines use a literary serif (Fraunces stand-in via system Serif); + * body/UI use a clean sans (Inter stand-in via system Sans). Swappable for + * bundled/downloadable fonts later without touching call-sites. + */ +val DisplayFamily = FontFamily.Serif +val BodyFamily = FontFamily.SansSerif + +val SlovkoTypography = Typography( + displayLarge = TextStyle( + fontFamily = DisplayFamily, fontWeight = FontWeight.SemiBold, + fontSize = 44.sp, lineHeight = 50.sp, + ), + displayMedium = TextStyle( + fontFamily = DisplayFamily, fontWeight = FontWeight.SemiBold, + fontSize = 34.sp, lineHeight = 40.sp, + ), + headlineLarge = TextStyle( + fontFamily = DisplayFamily, fontWeight = FontWeight.SemiBold, + fontSize = 26.sp, lineHeight = 32.sp, + ), + headlineMedium = TextStyle( + fontFamily = DisplayFamily, fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, lineHeight = 28.sp, + ), + titleLarge = TextStyle( + fontFamily = BodyFamily, fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, lineHeight = 26.sp, + ), + titleMedium = TextStyle( + fontFamily = BodyFamily, fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, lineHeight = 22.sp, + ), + bodyLarge = TextStyle( + fontFamily = BodyFamily, fontWeight = FontWeight.Normal, + fontSize = 17.sp, lineHeight = 24.sp, + ), + bodyMedium = TextStyle( + fontFamily = BodyFamily, fontWeight = FontWeight.Normal, + fontSize = 15.sp, lineHeight = 22.sp, + ), + labelLarge = TextStyle( + fontFamily = BodyFamily, fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.4.sp, + ), + labelMedium = TextStyle( + fontFamily = BodyFamily, fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp, + ), +) diff --git a/slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt b/slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt new file mode 100644 index 0000000..50b391b --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt @@ -0,0 +1,374 @@ +package com.slovko.core.designsystem.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.RepeatMode +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import com.slovko.core.designsystem.LocalReducedMotion +import com.slovko.core.designsystem.slovkoColors + +enum class OptionState { DEFAULT, SELECTED, CORRECT, WRONG } +enum class NodeState { LOCKED, AVAILABLE, COMPLETE } + +@Composable +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.height(56.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } +} + +@Composable +fun SecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = modifier.height(56.dp), + shape = RoundedCornerShape(12.dp), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.outline), + ) { + Text(text, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +fun ProgressRing( + progress: Float, + modifier: Modifier = Modifier, + size: Int = 56, + stroke: Float = 6f, + content: @Composable () -> Unit = {}, +) { + val reduced = LocalReducedMotion.current + val animated by animateFloatAsState( + targetValue = progress.coerceIn(0f, 1f), + animationSpec = tween(if (reduced) 0 else 600), + label = "ring", + ) + val track = MaterialTheme.colorScheme.surfaceVariant + val fill = MaterialTheme.colorScheme.primary + Box(modifier.size(size.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.size(size.dp)) { + drawArc( + color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + drawArc( + color = fill, startAngle = -90f, sweepAngle = 360f * animated, useCenter = false, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + } + content() + } +} + +@Composable +fun XpBar(progress: Float, modifier: Modifier = Modifier, label: String? = null) { + val gold = MaterialTheme.slovkoColors.gold + val reduced = LocalReducedMotion.current + val animated by animateFloatAsState( + progress.coerceIn(0f, 1f), tween(if (reduced) 0 else 500), label = "xp", + ) + Column(modifier) { + Box( + Modifier + .fillMaxWidth() + .height(12.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + Modifier + .fillMaxWidth(animated) + .height(12.dp) + .clip(RoundedCornerShape(8.dp)) + .background(gold), + ) + } + if (label != null) { + Spacer(Modifier.height(4.dp)) + Text(label, style = MaterialTheme.typography.labelMedium) + } + } +} + +@Composable +fun StreakFlame(count: Int, modifier: Modifier = Modifier, frozen: Boolean = false) { + val color = if (frozen) MaterialTheme.slovkoColors.ice else MaterialTheme.slovkoColors.gold + Row(modifier.wrapContentSize(), verticalAlignment = Alignment.CenterVertically) { + Text(if (frozen) "🧊" else "🔥", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.width(4.dp)) + Text( + "$count", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = color, + ) + } +} + +@Composable +fun OptionChip( + text: String, + state: OptionState, + onClick: () -> Unit, + modifier: Modifier = Modifier, + number: Int? = null, +) { + val scheme = MaterialTheme.colorScheme + val slovko = MaterialTheme.slovkoColors + val container = when (state) { + OptionState.DEFAULT -> scheme.surfaceContainer + OptionState.SELECTED -> scheme.primaryContainer + OptionState.CORRECT -> slovko.success.copy(alpha = 0.18f) + OptionState.WRONG -> scheme.errorContainer + } + val border = when (state) { + OptionState.SELECTED -> scheme.primary + OptionState.CORRECT -> slovko.success + OptionState.WRONG -> scheme.error + OptionState.DEFAULT -> scheme.outline + } + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = container, + border = BorderStroke(2.dp, border), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (number != null) { + Text( + "$number", + style = MaterialTheme.typography.labelLarge, + color = scheme.onSurfaceVariant, + modifier = Modifier.width(20.dp), + ) + } + Text(text, style = MaterialTheme.typography.bodyLarge) + } + } +} + +@Composable +fun WordTile( + text: String, + modifier: Modifier = Modifier, + onSpeak: (() -> Unit)? = null, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Row( + Modifier + .clickable(enabled = onSpeak != null) { onSpeak?.invoke() } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text, style = MaterialTheme.typography.titleMedium) + if (onSpeak != null) { + Spacer(Modifier.width(8.dp)) + Icon( + Icons.Filled.VolumeUp, contentDescription = "Play audio", + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +fun SkillNode( + state: NodeState, + glyph: String, + progress: Float, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val reduced = LocalReducedMotion.current + val breathing = rememberInfiniteTransition(label = "node") + val scale by breathing.animateFloat( + initialValue = 1f, + targetValue = if (state == NodeState.AVAILABLE && !reduced) 1.06f else 1f, + animationSpec = infiniteRepeatable(tween(1100), RepeatMode.Reverse), + label = "scale", + ) + val bg = when (state) { + NodeState.LOCKED -> MaterialTheme.colorScheme.surfaceVariant + NodeState.AVAILABLE -> MaterialTheme.colorScheme.primaryContainer + NodeState.COMPLETE -> MaterialTheme.slovkoColors.gold + } + Box(contentAlignment = Alignment.Center, modifier = modifier) { + if (state != NodeState.LOCKED) { + ProgressRing(progress = progress, size = 84, stroke = 8f) + } + Box( + Modifier + .size(72.dp) + .scale(scale) + .clip(CircleShape) + .background(bg) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Text( + if (state == NodeState.COMPLETE) "👑" else glyph, + style = MaterialTheme.typography.headlineMedium, + ) + } + } +} + +@Composable +fun CicmanyDivider(modifier: Modifier = Modifier) { + val color = MaterialTheme.slovkoColors.cicmanyLine + Canvas( + modifier + .fillMaxWidth() + .height(12.dp) + .padding(horizontal = 16.dp), + ) { + val n = (size.width / 24f).toInt().coerceAtLeast(1) + val w = size.width / n + for (i in 0 until n) { + val cx = i * w + w / 2 + val cy = size.height / 2 + val r = size.height / 2.5f + val path = androidx.compose.ui.graphics.Path().apply { + moveTo(cx, cy - r); lineTo(cx + r, cy); lineTo(cx, cy + r); lineTo(cx - r, cy); close() + } + drawPath(path, color) + } + } +} + +@Composable +fun MajaMascot(pose: String, modifier: Modifier = Modifier, size: Int = 96) { + val accessory = when (pose.lowercase()) { + "celebrating", "proud" -> "🎉" + "thinking" -> "💭" + "sleepy" -> "😴" + "worried" -> "😟" + "listening" -> "🎧" + else -> "👋" + } + Box(modifier.size(size.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(size.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text("🦊", style = MaterialTheme.typography.displayMedium) + } + Text( + accessory, + modifier = Modifier + .align(Alignment.TopEnd), + style = MaterialTheme.typography.titleLarge, + ) + } +} + +@Composable +fun ConfettiOverlay(show: Boolean, modifier: Modifier = Modifier) { + val reduced = LocalReducedMotion.current + AnimatedVisibility(visible = show) { + if (reduced) { + Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("🎉", style = MaterialTheme.typography.displayLarge) + } + return@AnimatedVisibility + } + val transition = rememberInfiniteTransition(label = "confetti") + val t by transition.animateFloat( + 0f, 1f, infiniteRepeatable(tween(1400), RepeatMode.Restart), label = "fall", + ) + val colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.slovkoColors.gold, + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.tertiary, + ) + Canvas(modifier.fillMaxWidth().height(220.dp)) { + val cols = 12 + for (i in 0 until cols) { + val x = size.width * (i + 0.5f) / cols + val phase = (t + i * 0.08f) % 1f + val y = size.height * phase + drawCircle( + color = colors[i % colors.size], + radius = 8f, + center = Offset(x, y), + ) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/core/notification/Notifications.kt b/slovko/app/src/main/java/com/slovko/core/notification/Notifications.kt new file mode 100644 index 0000000..f7607d5 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/core/notification/Notifications.kt @@ -0,0 +1,116 @@ +package com.slovko.core.notification + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationChannelGroup +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.slovko.R + +/** Notification channels (DESIGN.md §14). */ +object NotificationChannels { + const val GROUP = "slovko" + const val DAILY = "daily_practice" + const val STREAK = "streak" + const val REVIEWS = "reviews" + const val REENGAGE = "reengage" + const val ACHIEVEMENTS = "achievements" + + fun createAll(context: Context) { + val mgr = context.getSystemService(NotificationManager::class.java) ?: return + mgr.createNotificationChannelGroup( + NotificationChannelGroup(GROUP, context.getString(R.string.channel_group)), + ) + fun channel(id: String, nameRes: Int, descRes: Int, importance: Int) { + val ch = NotificationChannel(id, context.getString(nameRes), importance).apply { + description = context.getString(descRes) + group = GROUP + } + mgr.createNotificationChannel(ch) + } + channel(DAILY, R.string.channel_daily_name, R.string.channel_daily_desc, NotificationManager.IMPORTANCE_DEFAULT) + channel(STREAK, R.string.channel_streak_name, R.string.channel_streak_desc, NotificationManager.IMPORTANCE_HIGH) + channel(REVIEWS, R.string.channel_reviews_name, R.string.channel_reviews_desc, NotificationManager.IMPORTANCE_LOW) + channel(REENGAGE, R.string.channel_reengage_name, R.string.channel_reengage_desc, NotificationManager.IMPORTANCE_DEFAULT) + channel(ACHIEVEMENTS, R.string.channel_achievements_name, R.string.channel_achievements_desc, NotificationManager.IMPORTANCE_DEFAULT) + } +} + +/** Rotating, shame-free copy that always teaches a real Slovak phrase (DESIGN.md §14). */ +object NotificationCopyProvider { + data class Copy(val id: String, val title: String, val body: String) + + private val daily = listOf( + Copy("d1", "Čas na slovenčinu ☕", "Ahoj! 2 minutes of Slovak? \"ahoj\" = hi."), + Copy("d2", "Daily Brew is ready", "\"Ako sa máš?\" — How are you? Tap to practice."), + Copy("d3", "Pár slov?", "Just a few words today. \"prosím\" = please."), + Copy("d4", "Slovko time", "\"ďakujem\" = thank you. Keep it warm today."), + ) + private val streak = listOf( + Copy("s1", "Don't lose your flame 🔥", "A quick lesson keeps your streak alive."), + Copy("s2", "Your streak misses you", "5 minutes saves it. \"poďme!\" = let's go!"), + ) + private val reviews = listOf( + Copy("r1", "Cards are ready", "Some words are due for review. Quick refresh?"), + Copy("r2", "Keep them fresh", "Review now so today's words stick."), + ) + private val reengage = listOf( + Copy("e1", "Ešte si tu? 👋", "Slovak's waiting. \"vitaj späť\" = welcome back."), + Copy("e2", "One small word", "\"čau\" = hi/bye. Come say it back."), + ) + + fun forChannel(channelId: String, lastId: String?): Copy { + val pool = when (channelId) { + NotificationChannels.STREAK -> streak + NotificationChannels.REVIEWS -> reviews + NotificationChannels.REENGAGE -> reengage + else -> daily + } + return pool.firstOrNull { it.id != lastId } ?: pool.first() + } +} + +/** Builds + posts notifications with a deep-link tap action. */ +object NotificationBuilders { + + private fun deepLinkIntent(context: Context, host: String): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("slovko://$host")).apply { + setPackage(context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + return PendingIntent.getActivity( + context, host.hashCode(), intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + fun post( + context: Context, + channelId: String, + notificationId: Int, + copy: NotificationCopyProvider.Copy, + deepLinkHost: String = "practice", + ) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) return + + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_stat_slovko) + .setContentTitle(copy.title) + .setContentText(copy.body) + .setStyle(NotificationCompat.BigTextStyle().bigText(copy.body)) + .setContentIntent(deepLinkIntent(context, deepLinkHost)) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + runCatching { NotificationManagerCompat.from(context).notify(notificationId, notification) } + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/AppJson.kt b/slovko/app/src/main/java/com/slovko/data/AppJson.kt new file mode 100644 index 0000000..b115b45 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/AppJson.kt @@ -0,0 +1,11 @@ +package com.slovko.data + +import kotlinx.serialization.json.Json + +/** Shared lenient JSON for seeding and for stored JSON columns. */ +val AppJson: Json = Json { + ignoreUnknownKeys = true + isLenient = true + coerceInputValues = true + encodeDefaults = true +} diff --git a/slovko/app/src/main/java/com/slovko/data/ai/ChatPartner.kt b/slovko/app/src/main/java/com/slovko/data/ai/ChatPartner.kt new file mode 100644 index 0000000..9966c92 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/ai/ChatPartner.kt @@ -0,0 +1,98 @@ +package com.slovko.data.ai + +import com.slovko.domain.model.ChatMessage +import com.slovko.domain.model.ChatScenario +import com.slovko.domain.repository.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton + +/** A conversation partner for the Chat track. Offline by default. */ +interface ChatPartner { + suspend fun reply(scenario: ChatScenario, history: List): Result +} + +/** + * Default offline partner: walks the scenario's scripted partner turns; when the + * script is exhausted, offers warm, encouraging free-form closers. Fully deterministic. + */ +@Singleton +class ScriptedChatPartner @Inject constructor() : ChatPartner { + + private val closers = listOf( + "Super, ide ti to! 👏", + "Presne tak. Ešte niečo?", + "Pekne po slovensky! 😄", + "Jasné, dohodnuté. Maj sa!", + ) + + override suspend fun reply(scenario: ChatScenario, history: List): Result { + val partnerSoFar = history.count { it.role == "partner" } + val next = scenario.turns.getOrNull(partnerSoFar)?.sk + val line = next ?: closers[partnerSoFar % closers.size] + return Result.success(line) + } +} + +/** + * Opt-in remote partner using a user-supplied OpenAI-compatible endpoint/key. + * Uses HttpURLConnection (no extra deps). Falls back to scripted on any error. + */ +@Singleton +class RemoteChatPartner @Inject constructor( + private val settings: SettingsRepository, + private val fallback: ScriptedChatPartner, +) : ChatPartner { + + override suspend fun reply(scenario: ChatScenario, history: List): Result { + val cfg = settings.current() + if (!cfg.aiEnabled || cfg.aiEndpoint.isBlank()) { + return fallback.reply(scenario, history) + } + return withContext(Dispatchers.IO) { + runCatching { + val body = JSONObject().apply { + put("model", "gpt-4o-mini") + val messages = JSONArray() + messages.put( + JSONObject().put("role", "system").put( + "content", + "You are a friendly Slovak friend texting. Reply ONLY in casual, modern Slovak, " + + "1-2 short sentences. Scenario: ${scenario.description}", + ), + ) + history.takeLast(12).forEach { m -> + val role = if (m.role == "user") "user" else "assistant" + messages.put(JSONObject().put("role", role).put("content", m.textSk)) + } + put("messages", messages) + put("temperature", 0.7) + }.toString() + + val conn = (URL(cfg.aiEndpoint).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = 15000 + readTimeout = 20000 + setRequestProperty("Content-Type", "application/json") + if (cfg.aiApiKey.isNotBlank()) { + setRequestProperty("Authorization", "Bearer ${cfg.aiApiKey}") + } + } + conn.outputStream.use { it.write(body.toByteArray()) } + val text = conn.inputStream.bufferedReader().use { it.readText() } + val content = JSONObject(text) + .getJSONArray("choices").getJSONObject(0) + .getJSONObject("message").getString("content").trim() + content.ifBlank { error("empty reply") } + }.recoverCatching { + fallback.reply(scenario, history).getOrThrow() + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/audio/PronunciationPlayer.kt b/slovko/app/src/main/java/com/slovko/data/audio/PronunciationPlayer.kt new file mode 100644 index 0000000..b7a2bd9 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/audio/PronunciationPlayer.kt @@ -0,0 +1,43 @@ +package com.slovko.data.audio + +import android.content.Context +import android.speech.tts.TextToSpeech +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** Speaks Slovak text. TTS-backed; abstracted so bundled MP3s can be added later. */ +interface PronunciationPlayer { + fun speak(text: String, slow: Boolean = false) + fun stop() + fun shutdown() +} + +@Singleton +class TtsManager @Inject constructor( + @ApplicationContext context: Context, +) : PronunciationPlayer { + + private var ready = false + private val tts: TextToSpeech = TextToSpeech(context.applicationContext) { status -> + if (status == TextToSpeech.SUCCESS) { + runCatching { tts.language = Locale("sk", "SK") } + ready = true + } + } + + override fun speak(text: String, slow: Boolean) { + if (!ready || text.isBlank()) return + tts.setSpeechRate(if (slow) 0.7f else 1.0f) + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, text.hashCode().toString()) + } + + override fun stop() { + runCatching { tts.stop() } + } + + override fun shutdown() { + runCatching { tts.shutdown() } + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/datastore/PrefKeys.kt b/slovko/app/src/main/java/com/slovko/data/datastore/PrefKeys.kt new file mode 100644 index 0000000..bd01b8d --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/datastore/PrefKeys.kt @@ -0,0 +1,35 @@ +package com.slovko.data.datastore + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +object PrefKeys { + val SEED_VERSION = intPreferencesKey("seed_version") + + val ONBOARDED = booleanPreferencesKey("onboarded") + val DAILY_GOAL = intPreferencesKey("daily_goal_xp") + val TARGET_RETENTION = floatPreferencesKey("target_retention") + val REMINDER_HOUR = intPreferencesKey("reminder_hour") + val REMINDER_MINUTE = intPreferencesKey("reminder_minute") + val SMART_TIMING = booleanPreferencesKey("smart_timing") + val DAILY_REMINDER = booleanPreferencesKey("daily_reminder_on") + val STREAK_REMINDER = booleanPreferencesKey("streak_reminder_on") + val REVIEW_REMINDER = booleanPreferencesKey("review_reminder_on") + val REENGAGE = booleanPreferencesKey("reengage_on") + val QUIET_START = intPreferencesKey("quiet_start") + val QUIET_END = intPreferencesKey("quiet_end") + val SOUND_FX = booleanPreferencesKey("sound_fx") + val AUTOPLAY = booleanPreferencesKey("autoplay_audio") + val SPEAKING = booleanPreferencesKey("speaking_enabled") + val CHALLENGE = booleanPreferencesKey("challenge_mode") + val REDUCED_MOTION = booleanPreferencesKey("reduced_motion") + val THEME_MODE = stringPreferencesKey("theme_mode") + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val AI_ENDPOINT = stringPreferencesKey("ai_endpoint") + val AI_KEY = stringPreferencesKey("ai_key") + val AI_ENABLED = booleanPreferencesKey("ai_enabled") + val LAST_COPY_ID = stringPreferencesKey("last_copy_id") + val LAST_REENGAGE_DAY = intPreferencesKey("last_reengage_day") +} diff --git a/slovko/app/src/main/java/com/slovko/data/db/SlovkoDatabase.kt b/slovko/app/src/main/java/com/slovko/data/db/SlovkoDatabase.kt new file mode 100644 index 0000000..b5592a8 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/SlovkoDatabase.kt @@ -0,0 +1,59 @@ +package com.slovko.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.slovko.data.db.dao.ChatDao +import com.slovko.data.db.dao.ContentDao +import com.slovko.data.db.dao.GamificationDao +import com.slovko.data.db.dao.ProgressDao +import com.slovko.data.db.dao.SrsDao +import com.slovko.data.db.entity.AchievementEntity +import com.slovko.data.db.entity.ChatMessageEntity +import com.slovko.data.db.entity.ChatScenarioEntity +import com.slovko.data.db.entity.CompletedLessonEntity +import com.slovko.data.db.entity.DailyGoalEntity +import com.slovko.data.db.entity.ExerciseEntity +import com.slovko.data.db.entity.LeagueWeekEntity +import com.slovko.data.db.entity.LessonEntity +import com.slovko.data.db.entity.PhraseEntity +import com.slovko.data.db.entity.QuestEntity +import com.slovko.data.db.entity.SessionLogEntity +import com.slovko.data.db.entity.SkillEntity +import com.slovko.data.db.entity.SrsStateEntity +import com.slovko.data.db.entity.StreakLogEntity +import com.slovko.data.db.entity.UserProfileEntity +import com.slovko.data.db.entity.VocabCardEntity + +@Database( + entities = [ + SkillEntity::class, + LessonEntity::class, + ExerciseEntity::class, + VocabCardEntity::class, + ChatScenarioEntity::class, + PhraseEntity::class, + SrsStateEntity::class, + UserProfileEntity::class, + DailyGoalEntity::class, + StreakLogEntity::class, + SessionLogEntity::class, + CompletedLessonEntity::class, + AchievementEntity::class, + QuestEntity::class, + LeagueWeekEntity::class, + ChatMessageEntity::class, + ], + version = 1, + exportSchema = false, +) +abstract class SlovkoDatabase : RoomDatabase() { + abstract fun contentDao(): ContentDao + abstract fun srsDao(): SrsDao + abstract fun progressDao(): ProgressDao + abstract fun gamificationDao(): GamificationDao + abstract fun chatDao(): ChatDao + + companion object { + const val NAME = "slovko.db" + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/db/dao/ChatDao.kt b/slovko/app/src/main/java/com/slovko/data/db/dao/ChatDao.kt new file mode 100644 index 0000000..2b4f829 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/dao/ChatDao.kt @@ -0,0 +1,26 @@ +package com.slovko.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import com.slovko.data.db.entity.ChatMessageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatDao { + + @Query("SELECT * FROM chat_messages WHERE scenarioId = :scenarioId ORDER BY createdAt ASC, id ASC") + fun observeMessages(scenarioId: String): Flow> + + @Query("SELECT * FROM chat_messages WHERE scenarioId = :scenarioId ORDER BY createdAt ASC, id ASC") + suspend fun getMessages(scenarioId: String): List + + @Insert + suspend fun insert(message: ChatMessageEntity): Long + + @Query("SELECT COUNT(*) FROM chat_messages WHERE scenarioId = :scenarioId") + suspend fun count(scenarioId: String): Int + + @Query("DELETE FROM chat_messages WHERE scenarioId = :scenarioId") + suspend fun deleteForScenario(scenarioId: String) +} diff --git a/slovko/app/src/main/java/com/slovko/data/db/dao/ContentDao.kt b/slovko/app/src/main/java/com/slovko/data/db/dao/ContentDao.kt new file mode 100644 index 0000000..d9f584e --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/dao/ContentDao.kt @@ -0,0 +1,84 @@ +package com.slovko.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.slovko.data.db.entity.ChatScenarioEntity +import com.slovko.data.db.entity.ExerciseEntity +import com.slovko.data.db.entity.LessonEntity +import com.slovko.data.db.entity.PhraseEntity +import com.slovko.data.db.entity.SkillEntity +import com.slovko.data.db.entity.VocabCardEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ContentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSkills(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLessons(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertExercises(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVocab(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertScenarios(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPhrases(items: List) + + @Query("SELECT * FROM skills ORDER BY unitOrder, orderIndex") + fun observeSkills(): Flow> + + @Query("SELECT * FROM skills ORDER BY unitOrder, orderIndex") + suspend fun getAllSkills(): List + + @Query("SELECT * FROM skills WHERE id = :id") + suspend fun getSkill(id: String): SkillEntity? + + @Query("SELECT * FROM lessons WHERE skillId = :skillId ORDER BY orderIndex") + suspend fun getLessonsForSkill(skillId: String): List + + @Query("SELECT * FROM lessons ORDER BY orderIndex") + suspend fun getAllLessons(): List + + @Query("SELECT * FROM lessons WHERE id = :id") + suspend fun getLesson(id: String): LessonEntity? + + @Query("SELECT * FROM exercises WHERE lessonId = :lessonId ORDER BY orderIndex") + suspend fun getExercisesForLesson(lessonId: String): List + + @Query("SELECT * FROM vocab_cards WHERE id = :id") + suspend fun getVocab(id: String): VocabCardEntity? + + @Query("SELECT * FROM vocab_cards") + suspend fun getAllVocab(): List + + @Query("SELECT COUNT(*) FROM vocab_cards") + suspend fun vocabCount(): Int + + @Query("SELECT * FROM chat_scenarios ORDER BY orderIndex") + fun observeScenarios(): Flow> + + @Query("SELECT * FROM chat_scenarios WHERE id = :id") + suspend fun getScenario(id: String): ChatScenarioEntity? + + @Query("SELECT * FROM phrases ORDER BY orderIndex") + fun observePhrases(): Flow> + + @Query("SELECT COUNT(*) FROM skills") + suspend fun skillCount(): Int + + @Query("DELETE FROM skills") suspend fun clearSkills() + @Query("DELETE FROM lessons") suspend fun clearLessons() + @Query("DELETE FROM exercises") suspend fun clearExercises() + @Query("DELETE FROM vocab_cards") suspend fun clearVocab() + @Query("DELETE FROM chat_scenarios") suspend fun clearScenarios() + @Query("DELETE FROM phrases") suspend fun clearPhrases() +} diff --git a/slovko/app/src/main/java/com/slovko/data/db/dao/GamificationDao.kt b/slovko/app/src/main/java/com/slovko/data/db/dao/GamificationDao.kt new file mode 100644 index 0000000..cfe72d2 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/dao/GamificationDao.kt @@ -0,0 +1,51 @@ +package com.slovko.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.slovko.data.db.entity.AchievementEntity +import com.slovko.data.db.entity.LeagueWeekEntity +import com.slovko.data.db.entity.QuestEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface GamificationDao { + + // ---- achievements ---- + @Query("SELECT * FROM achievements ORDER BY orderIndex") + fun observeAchievements(): Flow> + + @Query("SELECT * FROM achievements ORDER BY orderIndex") + suspend fun getAchievements(): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAchievements(items: List) + + @Update + suspend fun updateAchievement(item: AchievementEntity) + + // ---- quests ---- + @Query("SELECT * FROM quests WHERE dateAssigned = :date ORDER BY id") + fun observeQuests(date: String): Flow> + + @Query("SELECT * FROM quests WHERE dateAssigned = :date ORDER BY id") + suspend fun getQuests(date: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertQuests(items: List) + + @Update + suspend fun updateQuest(item: QuestEntity) + + @Query("DELETE FROM quests WHERE dateAssigned != :keepDate") + suspend fun clearOldQuests(keepDate: String) + + // ---- league ---- + @Query("SELECT * FROM league_week WHERE weekId = :weekId") + suspend fun getWeek(weekId: String): LeagueWeekEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertWeek(week: LeagueWeekEntity) +} diff --git a/slovko/app/src/main/java/com/slovko/data/db/dao/ProgressDao.kt b/slovko/app/src/main/java/com/slovko/data/db/dao/ProgressDao.kt new file mode 100644 index 0000000..e884579 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/dao/ProgressDao.kt @@ -0,0 +1,69 @@ +package com.slovko.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.slovko.data.db.entity.CompletedLessonEntity +import com.slovko.data.db.entity.DailyGoalEntity +import com.slovko.data.db.entity.SessionLogEntity +import com.slovko.data.db.entity.StreakLogEntity +import com.slovko.data.db.entity.UserProfileEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ProgressDao { + + // ---- profile ---- + @Query("SELECT * FROM user_profile WHERE id = 1") + fun observeProfile(): Flow + + @Query("SELECT * FROM user_profile WHERE id = 1") + suspend fun getProfile(): UserProfileEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertProfile(profile: UserProfileEntity) + + // ---- daily goal ---- + @Query("SELECT * FROM daily_goal WHERE dateIso = :date") + fun observeGoal(date: String): Flow + + @Query("SELECT * FROM daily_goal WHERE dateIso = :date") + suspend fun getGoal(date: String): DailyGoalEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertGoal(goal: DailyGoalEntity) + + @Query("SELECT * FROM daily_goal ORDER BY dateIso DESC LIMIT :limit") + fun observeRecentGoals(limit: Int): Flow> + + @Query("SELECT * FROM daily_goal ORDER BY dateIso DESC LIMIT :limit") + suspend fun recentGoals(limit: Int): List + + // ---- streak ---- + @Query("SELECT * FROM streak_log ORDER BY dateIso DESC LIMIT :limit") + suspend fun recentStreak(limit: Int): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertStreak(log: StreakLogEntity) + + // ---- sessions ---- + @Insert + suspend fun insertSession(session: SessionLogEntity) + + @Query("SELECT * FROM session_log WHERE startTs >= :since") + suspend fun sessionsSince(since: Long): List + + @Query("SELECT COUNT(*) FROM session_log WHERE startTs >= :startOfDay") + suspend fun sessionsTodayCount(startOfDay: Long): Int + + // ---- completed lessons ---- + @Query("SELECT * FROM completed_lessons") + fun observeCompleted(): Flow> + + @Query("SELECT lessonId FROM completed_lessons") + suspend fun completedIds(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertCompleted(lesson: CompletedLessonEntity) +} diff --git a/slovko/app/src/main/java/com/slovko/data/db/dao/SrsDao.kt b/slovko/app/src/main/java/com/slovko/data/db/dao/SrsDao.kt new file mode 100644 index 0000000..7428f9d --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/dao/SrsDao.kt @@ -0,0 +1,40 @@ +package com.slovko.data.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.slovko.data.db.entity.SrsStateEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface SrsDao { + + @Query("SELECT COUNT(*) FROM srs_state WHERE due <= :now") + fun observeDueCount(now: Long): Flow + + @Query("SELECT COUNT(*) FROM srs_state WHERE due <= :now") + suspend fun dueCount(now: Long): Int + + @Query("SELECT * FROM srs_state WHERE due <= :now ORDER BY due ASC LIMIT :limit") + suspend fun dueStates(now: Long, limit: Int): List + + @Query("SELECT * FROM srs_state WHERE state = 2 ORDER BY due ASC LIMIT :limit") + suspend fun reviewStates(limit: Int): List + + @Query("SELECT * FROM srs_state WHERE cardId = :cardId AND direction = :direction LIMIT 1") + suspend fun getState(cardId: String, direction: Int): SrsStateEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(state: SrsStateEntity): Long + + @Update + suspend fun update(state: SrsStateEntity) + + @Query("SELECT COUNT(*) FROM srs_state WHERE reps > 0") + suspend fun learnedCount(): Int + + @Query("SELECT COUNT(*) FROM srs_state WHERE reps > 0") + fun observeLearnedCount(): Flow +} diff --git a/slovko/app/src/main/java/com/slovko/data/db/entity/ContentEntities.kt b/slovko/app/src/main/java/com/slovko/data/db/entity/ContentEntities.kt new file mode 100644 index 0000000..ba180af --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/entity/ContentEntities.kt @@ -0,0 +1,97 @@ +package com.slovko.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** Content tables — seeded from bundled JSON. Stable slug PKs for clean re-seed. */ + +@Entity(tableName = "skills") +data class SkillEntity( + @PrimaryKey val id: String, + val unitId: String, + val unitName: String, + val unitCefr: String, + val unitOrder: Int, + val title: String, + val description: String, + val iconKey: String, + val colorKey: String, + val orderIndex: Int, + val cefrLevel: String, +) + +@Entity( + tableName = "lessons", + indices = [Index("skillId")], +) +data class LessonEntity( + @PrimaryKey val id: String, + val skillId: String, + val title: String, + val orderIndex: Int, + val type: String, + val xpReward: Int, +) + +@Entity( + tableName = "exercises", + indices = [Index("lessonId")], +) +data class ExerciseEntity( + @PrimaryKey val id: String, + val lessonId: String, + val orderIndex: Int, + val type: String, + val promptSk: String?, + val promptEn: String?, + val answer: String, + val acceptableJson: String, + val choicesJson: String, + val pairsJson: String, + val audioKey: String?, + val vocabCardId: String?, + val hint: String?, + val caseTag: String?, + val aspectTag: String?, + val register: String, +) + +@Entity(tableName = "vocab_cards") +data class VocabCardEntity( + @PrimaryKey val id: String, + val sk: String, + val en: String, + val partOfSpeech: String, + val ipa: String?, + val exampleSk: String?, + val exampleEn: String?, + val gender: String?, + val audioKey: String?, + val register: String, + val frequencyRank: Int, +) + +@Entity(tableName = "chat_scenarios") +data class ChatScenarioEntity( + @PrimaryKey val id: String, + val title: String, + val description: String, + val cefrLevel: String, + val starterLineSk: String, + val iconKey: String, + val locked: Boolean, + val orderIndex: Int, + val turnsJson: String, +) + +@Entity(tableName = "phrases") +data class PhraseEntity( + @PrimaryKey val id: String, + val sk: String, + val en: String, + val register: String, + val note: String?, + val vocabCardId: String?, + val orderIndex: Int, +) diff --git a/slovko/app/src/main/java/com/slovko/data/db/entity/GamificationEntities.kt b/slovko/app/src/main/java/com/slovko/data/db/entity/GamificationEntities.kt new file mode 100644 index 0000000..07f3e29 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/entity/GamificationEntities.kt @@ -0,0 +1,56 @@ +package com.slovko.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity(tableName = "achievements") +data class AchievementEntity( + @PrimaryKey val id: String, + val title: String, + val description: String, + val iconKey: String, + val threshold: Int, + val progress: Int, + val unlockedAtEpoch: Long?, + val tier: Int, + val orderIndex: Int, +) + +@Entity( + tableName = "quests", + indices = [Index("dateAssigned")], +) +data class QuestEntity( + @PrimaryKey val id: String, + val dateAssigned: String, + val title: String, + val kind: String, + val target: Int, + val progress: Int, + val rewardGems: Int, + val completed: Boolean, +) + +@Entity(tableName = "league_week") +data class LeagueWeekEntity( + @PrimaryKey val weekId: String, + val tier: String, + val startTs: Long, + val endTs: Long, + val trailingAvg: Int, +) + +@Entity( + tableName = "chat_messages", + indices = [Index("scenarioId")], +) +data class ChatMessageEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val scenarioId: String, + val role: String, + val textSk: String, + val textEnGloss: String?, + val createdAt: Long, + val turnIndex: Int, +) diff --git a/slovko/app/src/main/java/com/slovko/data/db/entity/ProgressEntities.kt b/slovko/app/src/main/java/com/slovko/data/db/entity/ProgressEntities.kt new file mode 100644 index 0000000..72a0201 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/db/entity/ProgressEntities.kt @@ -0,0 +1,69 @@ +package com.slovko.data.db.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** User progress, SRS state, and analytics. Never touched by content re-seed. */ + +@Entity(tableName = "user_profile") +data class UserProfileEntity( + @PrimaryKey val id: Int = 1, + val displayName: String, + val level: Int, + val totalXp: Int, + val gems: Int, + val crowns: Int, + val freezesHeld: Int, + val createdAtEpochDay: Long, + val avatarKey: String, +) + +@Entity( + tableName = "srs_state", + indices = [Index(value = ["cardId", "direction"], unique = true), Index("due"), Index("state")], +) +data class SrsStateEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val cardId: String, + val direction: Int, + val state: Int, + val stability: Double, + val difficulty: Double, + val due: Long, + val lastReview: Long?, + val reps: Int, + val lapses: Int, +) + +@Entity(tableName = "daily_goal") +data class DailyGoalEntity( + @PrimaryKey val dateIso: String, + val goalXp: Int, + val earnedXp: Int, + val lessonsCompleted: Int, + val met: Boolean, +) + +@Entity(tableName = "streak_log") +data class StreakLogEntity( + @PrimaryKey val dateIso: String, + val practiced: Boolean, + val frozen: Boolean, +) + +@Entity(tableName = "session_log") +data class SessionLogEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val startTs: Long, + val durationMs: Long, + val xpEarned: Int, + val hour: Int, +) + +@Entity(tableName = "completed_lessons") +data class CompletedLessonEntity( + @PrimaryKey val lessonId: String, + val completedAtEpoch: Long, + val bestAccuracy: Float, +) diff --git a/slovko/app/src/main/java/com/slovko/data/mapper/Mappers.kt b/slovko/app/src/main/java/com/slovko/data/mapper/Mappers.kt new file mode 100644 index 0000000..72d06f4 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/mapper/Mappers.kt @@ -0,0 +1,169 @@ +package com.slovko.data.mapper + +import com.slovko.data.AppJson +import com.slovko.data.db.entity.ChatScenarioEntity +import com.slovko.data.db.entity.ExerciseEntity +import com.slovko.data.db.entity.LessonEntity +import com.slovko.data.db.entity.PhraseEntity +import com.slovko.data.db.entity.SkillEntity +import com.slovko.data.db.entity.SrsStateEntity +import com.slovko.data.db.entity.VocabCardEntity +import com.slovko.data.seed.ExerciseDto +import com.slovko.data.seed.LessonDto +import com.slovko.data.seed.PhraseDto +import com.slovko.data.seed.ScenarioDto +import com.slovko.data.seed.SkillDto +import com.slovko.data.seed.TurnDto +import com.slovko.data.seed.UnitDto +import com.slovko.data.seed.VocabDto +import com.slovko.domain.model.AspectTag +import com.slovko.domain.model.CardDirection +import com.slovko.domain.model.CardState +import com.slovko.domain.model.CaseTag +import com.slovko.domain.model.ChatScenario +import com.slovko.domain.model.Exercise +import com.slovko.domain.model.ExerciseType +import com.slovko.domain.model.Lesson +import com.slovko.domain.model.LessonType +import com.slovko.domain.model.Phrase +import com.slovko.domain.model.Register +import com.slovko.domain.model.ReplyOption +import com.slovko.domain.model.ReviewCard +import com.slovko.domain.model.ScenarioTurn +import com.slovko.domain.model.Skill +import com.slovko.domain.model.VocabCard +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer + +// ---- enum parsing ---- +fun parseRegister(s: String?): Register = when (s?.lowercase()) { + "informal" -> Register.INFORMAL + "formal" -> Register.FORMAL + else -> Register.NEUTRAL +} + +fun parseExerciseType(s: String): ExerciseType = + runCatching { ExerciseType.valueOf(s.uppercase()) }.getOrDefault(ExerciseType.MCQ) + +fun parseLessonType(s: String): LessonType = + runCatching { LessonType.valueOf(s.uppercase()) }.getOrDefault(LessonType.PRACTICE) + +fun parseCaseTag(s: String?): CaseTag? = + s?.let { runCatching { CaseTag.valueOf(it.uppercase()) }.getOrNull() } + +fun parseAspectTag(s: String?): AspectTag? = + s?.let { runCatching { AspectTag.valueOf(it.uppercase()) }.getOrNull() } + +private val StringListSer = ListSerializer(String.serializer()) +private val PairsSer = ListSerializer(ListSerializer(String.serializer())) + +private fun List.toJson(): String = AppJson.encodeToString(StringListSer, this) +private fun String.toStringList(): List = + runCatching { AppJson.decodeFromString(StringListSer, this) }.getOrDefault(emptyList()) + +// ---- DTO -> Entity (seeding) ---- +fun SkillDto.toEntity(unit: UnitDto): SkillEntity = SkillEntity( + id = id, unitId = unitId, unitName = unit.name, unitCefr = unit.cefr, unitOrder = unit.order, + title = title, description = description, iconKey = iconKey, colorKey = colorKey, + orderIndex = orderIndex, cefrLevel = cefrLevel, +) + +fun LessonDto.toEntity(skillId: String): LessonEntity = LessonEntity( + id = id, skillId = skillId, title = title, orderIndex = orderIndex, + type = type.uppercase(), xpReward = xpReward, +) + +fun ExerciseDto.toEntity(lessonId: String): ExerciseEntity = ExerciseEntity( + id = id, lessonId = lessonId, orderIndex = orderIndex, type = type.uppercase(), + promptSk = promptSk, promptEn = promptEn, answer = answer, + acceptableJson = acceptable.toJson(), + choicesJson = choices.toJson(), + pairsJson = AppJson.encodeToString(PairsSer, pairs), + audioKey = audioKey, vocabCardId = vocabCardId, hint = hint, + caseTag = caseTag?.uppercase(), aspectTag = aspectTag?.uppercase(), + register = register.lowercase(), +) + +fun VocabDto.toEntity(): VocabCardEntity = VocabCardEntity( + id = id, sk = sk, en = en, partOfSpeech = partOfSpeech, ipa = ipa, + exampleSk = exampleSk, exampleEn = exampleEn, gender = gender, audioKey = audioKey, + register = register.lowercase(), frequencyRank = frequencyRank, +) + +fun ScenarioDto.toEntity(): ChatScenarioEntity = ChatScenarioEntity( + id = id, title = title, description = description, cefrLevel = cefrLevel, + starterLineSk = starterLineSk, iconKey = iconKey, locked = locked, orderIndex = orderIndex, + turnsJson = AppJson.encodeToString(ListSerializer(TurnDto.serializer()), turns), +) + +fun PhraseDto.toEntity(): PhraseEntity = PhraseEntity( + id = id, sk = sk, en = en, register = register.lowercase(), note = note, + vocabCardId = vocabCardId, orderIndex = orderIndex, +) + +// ---- Entity -> Domain ---- +fun ExerciseEntity.toDomain(): Exercise { + val pairs: List> = runCatching { + AppJson.decodeFromString(PairsSer, pairsJson) + .mapNotNull { if (it.size >= 2) it[0] to it[1] else null } + }.getOrDefault(emptyList()) + return Exercise( + id = id, lessonId = lessonId, orderIndex = orderIndex, type = parseExerciseType(type), + promptSk = promptSk, promptEn = promptEn, answer = answer, + acceptable = acceptableJson.toStringList(), + choices = choicesJson.toStringList(), + pairs = pairs, + audioKey = audioKey, vocabCardId = vocabCardId, hint = hint, + caseTag = parseCaseTag(caseTag), aspectTag = parseAspectTag(aspectTag), + register = parseRegister(register), + ) +} + +fun LessonEntity.toDomain(exercises: List): Lesson = Lesson( + id = id, skillId = skillId, title = title, orderIndex = orderIndex, + type = parseLessonType(type), xpReward = xpReward, exercises = exercises, +) + +fun SkillEntity.toDomain(lessons: List): Skill = Skill( + id = id, unitId = unitId, title = title, description = description, iconKey = iconKey, + colorKey = colorKey, orderIndex = orderIndex, cefrLevel = cefrLevel, lessons = lessons, +) + +fun VocabCardEntity.toDomain(): VocabCard = VocabCard( + id = id, sk = sk, en = en, partOfSpeech = partOfSpeech, ipa = ipa, + exampleSk = exampleSk, exampleEn = exampleEn, gender = gender, audioKey = audioKey, + register = parseRegister(register), frequencyRank = frequencyRank, +) + +fun PhraseEntity.toDomain(): Phrase = Phrase( + id = id, sk = sk, en = en, register = parseRegister(register), note = note, vocabCardId = vocabCardId, +) + +fun ChatScenarioEntity.toDomain(): ChatScenario { + val turns = runCatching { + AppJson.decodeFromString(ListSerializer(TurnDto.serializer()), turnsJson) + }.getOrDefault(emptyList()).map { t -> + ScenarioTurn( + role = t.role, + sk = t.sk, + enGloss = t.enGloss, + replies = t.replies.map { ReplyOption(it.sk, it.enGloss, it.natural) }, + ) + } + return ChatScenario( + id = id, title = title, description = description, cefrLevel = cefrLevel, + starterLineSk = starterLineSk, iconKey = iconKey, locked = locked, turns = turns, + ) +} + +fun SrsStateEntity.toReviewCard(card: VocabCard): ReviewCard = ReviewCard( + card = card, + direction = CardDirection.from(direction), + state = CardState.from(state), + stability = stability, + difficulty = difficulty, + due = due, + reps = reps, + lapses = lapses, + lastReviewMillis = lastReview, +) diff --git a/slovko/app/src/main/java/com/slovko/data/repository/ChatRepositoryImpl.kt b/slovko/app/src/main/java/com/slovko/data/repository/ChatRepositoryImpl.kt new file mode 100644 index 0000000..e0ce8cc --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/repository/ChatRepositoryImpl.kt @@ -0,0 +1,84 @@ +package com.slovko.data.repository + +import com.slovko.core.common.AppClock +import com.slovko.core.common.IoDispatcher +import com.slovko.data.ai.ChatPartner +import com.slovko.data.db.dao.ChatDao +import com.slovko.data.db.entity.ChatMessageEntity +import com.slovko.domain.model.ChatMessage +import com.slovko.domain.repository.ChatRepository +import com.slovko.domain.repository.ContentRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatRepositoryImpl @Inject constructor( + private val chatDao: ChatDao, + private val content: ContentRepository, + private val partner: ChatPartner, + private val clock: AppClock, + @IoDispatcher private val io: CoroutineDispatcher, +) : ChatRepository { + + override fun observeMessages(scenarioId: String): Flow> = + chatDao.observeMessages(scenarioId).map { list -> list.map { it.toDomain() } }.flowOn(io) + + override suspend fun startIfEmpty(scenarioId: String) = withContext(io) { + if (chatDao.count(scenarioId) > 0) return@withContext + val scenario = content.getScenario(scenarioId) ?: return@withContext + val firstTurn = scenario.turns.firstOrNull() + chatDao.insert( + ChatMessageEntity( + scenarioId = scenarioId, + role = "partner", + textSk = firstTurn?.sk ?: scenario.starterLineSk, + textEnGloss = firstTurn?.enGloss, + createdAt = clock.nowMillis(), + turnIndex = 0, + ), + ) + } + + override suspend fun sendUserMessage(scenarioId: String, textSk: String, glossEn: String?) { + withContext(io) { + val userCount = chatDao.getMessages(scenarioId).count { it.role == "user" } + chatDao.insert( + ChatMessageEntity( + scenarioId = scenarioId, role = "user", textSk = textSk, + textEnGloss = glossEn, createdAt = clock.nowMillis(), turnIndex = userCount, + ), + ) + } + } + + override suspend fun requestPartnerReply(scenarioId: String): Result = withContext(io) { + val scenario = content.getScenario(scenarioId) + ?: return@withContext Result.failure(IllegalStateException("Scenario not found")) + val history = chatDao.getMessages(scenarioId).map { it.toDomain() } + partner.reply(scenario, history).map { text -> + val id = chatDao.insert( + ChatMessageEntity( + scenarioId = scenarioId, role = "partner", textSk = text, + textEnGloss = null, createdAt = clock.nowMillis(), + turnIndex = history.count { it.role == "partner" }, + ), + ) + ChatMessage(id, scenarioId, "partner", text, null, clock.nowMillis()) + } + } + + override suspend fun reset(scenarioId: String) { + withContext(io) { + chatDao.deleteForScenario(scenarioId) + startIfEmpty(scenarioId) + } + } + + private fun ChatMessageEntity.toDomain() = + ChatMessage(id, scenarioId, role, textSk, textEnGloss, createdAt) +} diff --git a/slovko/app/src/main/java/com/slovko/data/repository/ContentRepositoryImpl.kt b/slovko/app/src/main/java/com/slovko/data/repository/ContentRepositoryImpl.kt new file mode 100644 index 0000000..30e3484 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/repository/ContentRepositoryImpl.kt @@ -0,0 +1,79 @@ +package com.slovko.data.repository + +import com.slovko.core.common.AppClock +import com.slovko.core.common.IoDispatcher +import com.slovko.data.db.dao.ContentDao +import com.slovko.data.mapper.toDomain +import com.slovko.data.seed.ContentSeeder +import com.slovko.domain.model.ChatScenario +import com.slovko.domain.model.CurriculumUnit +import com.slovko.domain.model.Lesson +import com.slovko.domain.model.Phrase +import com.slovko.domain.model.Skill +import com.slovko.domain.model.VocabCard +import com.slovko.domain.repository.ContentRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ContentRepositoryImpl @Inject constructor( + private val dao: ContentDao, + private val seeder: ContentSeeder, + private val clock: AppClock, + @IoDispatcher private val io: CoroutineDispatcher, +) : ContentRepository { + + override suspend fun ensureSeeded() = seeder.seedIfNeeded() + + override fun observeUnits(): Flow> = + dao.observeSkills().map { skillEntities -> + val units = LinkedHashMap>() + val meta = LinkedHashMap>() // name, cefr, order + for (e in skillEntities) { + val lessons = dao.getLessonsForSkill(e.id).map { it.toDomain(emptyList()) } + units.getOrPut(e.unitId) { mutableListOf() }.add(e.toDomain(lessons)) + meta.putIfAbsent(e.unitId, Triple(e.unitName, e.unitCefr, e.unitOrder)) + } + units.entries + .sortedBy { meta[it.key]?.third ?: 0 } + .map { (unitId, skills) -> + val (name, cefr, order) = meta[unitId]!! + CurriculumUnit(id = unitId, name = name, cefr = cefr, order = order, skills = skills) + } + }.flowOn(io) + + override suspend fun getLesson(lessonId: String): Lesson? { + val lesson = dao.getLesson(lessonId) ?: return null + val exercises = dao.getExercisesForLesson(lessonId).map { it.toDomain() } + return lesson.toDomain(exercises) + } + + override suspend fun getSkill(skillId: String): Skill? { + val skill = dao.getSkill(skillId) ?: return null + val lessons = dao.getLessonsForSkill(skillId).map { it.toDomain(emptyList()) } + return skill.toDomain(lessons) + } + + override fun observeScenarios(): Flow> = + dao.observeScenarios().map { list -> list.map { it.toDomain() } }.flowOn(io) + + override suspend fun getScenario(scenarioId: String): ChatScenario? = + dao.getScenario(scenarioId)?.toDomain() + + override fun observePhrases(): Flow> = + dao.observePhrases().map { list -> list.map { it.toDomain() } }.flowOn(io) + + override suspend fun getVocabCard(cardId: String): VocabCard? = + dao.getVocab(cardId)?.toDomain() + + override suspend fun wordOfTheDay(): VocabCard? { + val all = dao.getAllVocab().sortedBy { it.frequencyRank } + if (all.isEmpty()) return null + val idx = (clock.today().toEpochDay().mod(all.size)) + return all[idx].toDomain() + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/repository/GamificationRepositoryImpl.kt b/slovko/app/src/main/java/com/slovko/data/repository/GamificationRepositoryImpl.kt new file mode 100644 index 0000000..3e7a1a9 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/repository/GamificationRepositoryImpl.kt @@ -0,0 +1,225 @@ +package com.slovko.data.repository + +import com.slovko.core.common.AppClock +import com.slovko.core.common.IoDispatcher +import com.slovko.data.db.dao.GamificationDao +import com.slovko.data.db.dao.ProgressDao +import com.slovko.data.db.dao.SrsDao +import com.slovko.data.db.entity.LeagueWeekEntity +import com.slovko.data.db.entity.QuestEntity +import com.slovko.domain.league.LeagueEngine +import com.slovko.domain.model.Achievement +import com.slovko.domain.model.League +import com.slovko.domain.model.LeagueTier +import com.slovko.domain.model.Quest +import com.slovko.domain.repository.GamificationRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.time.LocalDate +import java.time.temporal.WeekFields +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.max + +@Singleton +class GamificationRepositoryImpl @Inject constructor( + private val dao: GamificationDao, + private val progressDao: ProgressDao, + private val srsDao: SrsDao, + private val leagueEngine: LeagueEngine, + private val clock: AppClock, + @IoDispatcher private val io: CoroutineDispatcher, +) : GamificationRepository { + + private fun todayIso() = clock.today().toString() + + // ---- quests ---- + override fun observeQuests(): Flow> = + dao.observeQuests(todayIso()).map { list -> + list.map { Quest(it.id, it.title, it.target, it.progress, it.rewardGems, it.completed) } + }.flowOn(io) + + override suspend fun rollDailyQuestsIfNeeded() = withContext(io) { + val date = todayIso() + if (dao.getQuests(date).isNotEmpty()) return@withContext + dao.clearOldQuests(date) + val seed = LocalDate.parse(date).toEpochDay() + val pool = listOf( + QuestEntity("$date-xp", date, "Earn 50 XP today", "xp", 50, 0, 20, false), + QuestEntity("$date-lessons", date, "Complete 2 lessons", "lessons", 2, 0, 15, false), + QuestEntity("$date-perfect", date, "Finish a perfect lesson", "perfect", 1, 0, 25, false), + QuestEntity("$date-xp2", date, "Earn 100 XP today", "xp", 100, 0, 30, false), + QuestEntity("$date-lessons3", date, "Complete 3 lessons", "lessons", 3, 0, 25, false), + ) + // pick a stable rotating subset of 3 + val start = (seed % pool.size).toInt() + val chosen = (0 until 3).map { pool[(start + it) % pool.size] } + .distinctBy { it.id } + dao.insertQuests(chosen) + } + + override suspend fun progressQuests(xp: Int, lessons: Int, perfect: Boolean) = withContext(io) { + rollDailyQuestsIfNeeded() + val quests = dao.getQuests(todayIso()) + for (q in quests) { + if (q.completed) continue + val delta = when (q.kind) { + "xp" -> xp + "lessons" -> lessons + "perfect" -> if (perfect) 1 else 0 + else -> 0 + } + if (delta == 0) continue + val newProgress = (q.progress + delta).coerceAtMost(q.target) + val done = newProgress >= q.target + dao.updateQuest(q.copy(progress = newProgress, completed = done)) + if (done && !q.completed) addGems(q.rewardGems) + } + } + + // ---- achievements ---- + override fun observeAchievements(): Flow> = + dao.observeAchievements().map { list -> + list.map { + Achievement(it.id, it.title, it.description, it.iconKey, it.threshold, it.progress, it.unlockedAtEpoch, it.tier) + } + }.flowOn(io) + + override suspend fun evaluateAchievements(): List = withContext(io) { + val profile = progressDao.getProfile() + val totalXp = profile?.totalXp ?: 0 + val gems = profile?.gems ?: 0 + val words = srsDao.learnedCount() + val completed = progressDao.completedIds().size + val streak = currentStreak() + + val metricFor: (String) -> Int? = { id -> + when (id) { + "prve_slovo" -> completed + "slovnik" -> words + "maratonec" -> totalXp + "ohnivak" -> streak + "verny" -> streak + "tyzden_vitazstva" -> streak + "zberatel" -> gems + else -> null + } + } + + val newlyUnlocked = mutableListOf() + for (a in dao.getAchievements()) { + val metric = metricFor(a.id) ?: continue + val progress = metric.coerceAtMost(a.threshold) + val unlock = progress >= a.threshold && a.unlockedAtEpoch == null + if (progress != a.progress || unlock) { + val updated = a.copy( + progress = progress, + unlockedAtEpoch = if (unlock) clock.nowMillis() else a.unlockedAtEpoch, + ) + dao.updateAchievement(updated) + if (unlock) { + addGems(a.tier * 25) + newlyUnlocked += Achievement( + updated.id, updated.title, updated.description, updated.iconKey, + updated.threshold, updated.progress, updated.unlockedAtEpoch, updated.tier, + ) + } + } + } + newlyUnlocked + } + + // ---- league ---- + override fun observeLeague(): Flow = + combine(progressDao.observeProfile(), progressDao.observeRecentGoals(14)) { _, _ -> + computeLeague() + }.flowOn(io) + + override suspend fun refreshLeague() { + withContext(io) { ensureWeek() } + } + + private suspend fun ensureWeek(): LeagueWeekEntity { + val weekId = isoWeekId(clock.today()) + dao.getWeek(weekId)?.let { return it } + val trailing = trailingDailyAvg() + val week = LeagueWeekEntity( + weekId = weekId, + tier = LeagueTier.BRONZE.name, + startTs = weekStart().atStartOfDay(clock.zone()).toInstant().toEpochMilli(), + endTs = weekStart().plusDays(7).atStartOfDay(clock.zone()).toInstant().toEpochMilli(), + trailingAvg = trailing, + ) + dao.upsertWeek(week) + return week + } + + private suspend fun computeLeague(): League { + val week = ensureWeek() + val profile = progressDao.getProfile() + val tier = runCatching { LeagueTier.valueOf(week.tier) }.getOrDefault(LeagueTier.BRONZE) + val start = weekStart() + val userWeekXp = progressDao.recentGoals(7) + .filter { !LocalDate.parse(it.dateIso).isBefore(start) } + .sumOf { it.earnedXp } + val now = clock.nowMillis() + val elapsedDays = ((now - week.startTs).toDouble() / 86_400_000.0).coerceIn(0.0, 7.0) + val daysRemaining = max(0, 7 - elapsedDays.toInt()) + return leagueEngine.standings( + weekId = week.weekId, + tier = tier, + userXp = userWeekXp, + userName = profile?.displayName ?: "Ty", + userAvatar = profile?.avatarKey ?: "fox", + userTrailingDailyAvg = week.trailingAvg, + daysElapsed = elapsedDays, + daysRemaining = daysRemaining, + ) + } + + override suspend fun addGems(amount: Int) { + withContext(io) { + val p = progressDao.getProfile() ?: return@withContext + progressDao.upsertProfile(p.copy(gems = max(0, p.gems + amount))) + } + } + + override suspend fun spendGems(amount: Int): Boolean = withContext(io) { + val p = progressDao.getProfile() ?: return@withContext false + if (p.gems < amount) return@withContext false + progressDao.upsertProfile(p.copy(gems = p.gems - amount)) + true + } + + // ---- helpers ---- + private fun weekStart(): LocalDate = + clock.today().with(WeekFields.ISO.dayOfWeek(), 1) + + private fun isoWeekId(date: LocalDate): String { + val week = date.get(WeekFields.ISO.weekOfWeekBasedYear()) + val year = date.get(WeekFields.ISO.weekBasedYear()) + return "%d-W%02d".format(year, week) + } + + private suspend fun trailingDailyAvg(): Int { + val goals = progressDao.recentGoals(7) + if (goals.isEmpty()) return 0 + return goals.sumOf { it.earnedXp } / max(1, goals.size) + } + + private suspend fun currentStreak(): Int { + val logs = progressDao.recentStreak(400).associateBy { it.dateIso } + var day = clock.today() + if (logs[day.toString()]?.let { it.practiced || it.frozen } != true) day = day.minusDays(1) + var count = 0 + while (logs[day.toString()]?.let { it.practiced || it.frozen } == true) { + if (logs[day.toString()]?.practiced == true) count++ + day = day.minusDays(1) + } + return count + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/repository/ProgressRepositoryImpl.kt b/slovko/app/src/main/java/com/slovko/data/repository/ProgressRepositoryImpl.kt new file mode 100644 index 0000000..1e7d74b --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/repository/ProgressRepositoryImpl.kt @@ -0,0 +1,208 @@ +package com.slovko.data.repository + +import com.slovko.core.common.AppClock +import com.slovko.core.common.IoDispatcher +import com.slovko.data.db.dao.ProgressDao +import com.slovko.data.db.dao.SrsDao +import com.slovko.data.db.entity.CompletedLessonEntity +import com.slovko.data.db.entity.DailyGoalEntity +import com.slovko.data.db.entity.SessionLogEntity +import com.slovko.data.db.entity.StreakLogEntity +import com.slovko.data.db.entity.UserProfileEntity +import com.slovko.domain.GamificationConfig +import com.slovko.domain.model.DailyGoal +import com.slovko.domain.model.HeatDay +import com.slovko.domain.model.UserStats +import com.slovko.domain.repository.ProgressRepository +import com.slovko.domain.repository.SettingsRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import java.time.LocalDate +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ProgressRepositoryImpl @Inject constructor( + private val dao: ProgressDao, + private val srsDao: SrsDao, + private val settings: SettingsRepository, + private val clock: AppClock, + @IoDispatcher private val io: CoroutineDispatcher, +) : ProgressRepository { + + private fun todayIso(): String = clock.today().toString() + private fun startOfTodayMillis(): Long = + clock.today().atStartOfDay(clock.zone()).toInstant().toEpochMilli() + + override suspend fun ensureProfile() = withContext(io) { + if (dao.getProfile() == null) { + dao.upsertProfile( + UserProfileEntity( + id = 1, displayName = "Ty", level = 1, totalXp = 0, gems = 0, + crowns = 0, freezesHeld = 0, createdAtEpochDay = clock.today().toEpochDay(), + avatarKey = "fox", + ), + ) + } + } + + override fun observeUserStats(): Flow = + combine(dao.observeProfile(), srsDao.observeLearnedCount()) { profile, learned -> + val p = profile ?: UserProfileEntity( + 1, "Ty", 1, 0, 0, 0, 0, clock.today().toEpochDay(), "fox", + ) + val (into, span) = GamificationConfig.levelProgress(p.totalXp) + val streaks = dao.recentStreak(400) + UserStats( + displayName = p.displayName, + level = GamificationConfig.levelForXp(p.totalXp), + totalXp = p.totalXp, + xpIntoLevel = into, + xpForNextLevel = span, + gems = p.gems, + crowns = p.crowns, + currentStreak = currentStreak(streaks), + longestStreak = longestStreak(streaks), + wordsLearned = learned, + avatarKey = p.avatarKey, + memberSinceEpochDay = p.createdAtEpochDay, + ) + }.flowOn(io) + + override fun observeDailyGoal(): Flow = + combine(dao.observeGoal(todayIso()), settings.settings) { goal, s -> + val target = s.dailyGoalXp + DailyGoal( + dateIso = todayIso(), + goalXp = goal?.goalXp ?: target, + earnedXp = goal?.earnedXp ?: 0, + lessonsCompleted = goal?.lessonsCompleted ?: 0, + met = goal?.met ?: false, + ) + }.flowOn(io) + + override fun observeHeatmap(days: Int): Flow> = + dao.observeRecentGoals(days).map { goals -> + goals.map { + HeatDay( + epochDay = LocalDate.parse(it.dateIso).toEpochDay(), + xp = it.earnedXp, + met = it.met, + ) + } + }.flowOn(io) + + override fun observeCompletedLessons(): Flow> = + dao.observeCompleted().map { list -> list.map { it.lessonId }.toSet() }.flowOn(io) + + override suspend fun completedLessonIds(): Set = withContext(io) { + dao.completedIds().toSet() + } + + override suspend fun recordLessonCompletion( + lessonId: String, + xpEarned: Int, + accuracy: Float, + durationMs: Long, + newWords: Int, + ): Int = withContext(io) { + val now = clock.nowMillis() + dao.upsertCompleted(CompletedLessonEntity(lessonId, now, accuracy)) + dao.insertSession( + SessionLogEntity( + startTs = now - durationMs, durationMs = durationMs, xpEarned = xpEarned, + hour = clock.today().let { java.time.LocalDateTime.now(clock.zone()).hour }, + ), + ) + applyXp(xpEarned, lessonDelta = 1) + } + + override suspend fun addXp(amount: Int) { + withContext(io) { applyXp(amount, lessonDelta = 0) } + } + + /** Adds XP to profile + today's goal; flips streak when the goal is newly met. Returns streak delta. */ + private suspend fun applyXp(amount: Int, lessonDelta: Int): Int { + val profile = dao.getProfile() ?: run { ensureProfile(); dao.getProfile()!! } + val newTotal = profile.totalXp + amount + dao.upsertProfile(profile.copy(totalXp = newTotal, level = GamificationConfig.levelForXp(newTotal))) + + val date = todayIso() + val target = settings.current().dailyGoalXp + val existing = dao.getGoal(date) + val wasMet = existing?.met ?: false + val earned = (existing?.earnedXp ?: 0) + amount + val met = earned >= target + dao.upsertGoal( + DailyGoalEntity( + dateIso = date, + goalXp = existing?.goalXp ?: target, + earnedXp = earned, + lessonsCompleted = (existing?.lessonsCompleted ?: 0) + lessonDelta, + met = met, + ), + ) + + return if (met && !wasMet) { + dao.upsertStreak(StreakLogEntity(date, practiced = true, frozen = false)) + 1 + } else 0 + } + + override suspend fun setDisplayName(name: String) = withContext(io) { + val p = dao.getProfile() ?: return@withContext + dao.upsertProfile(p.copy(displayName = name.ifBlank { p.displayName })) + } + + override suspend fun hasPracticedToday(): Boolean = withContext(io) { + dao.sessionsTodayCount(startOfTodayMillis()) > 0 + } + + override suspend fun modalPracticeHour(days: Int): Int? = withContext(io) { + val since = clock.nowMillis() - days * 86_400_000L + val sessions = dao.sessionsSince(since) + if (sessions.size < 5) return@withContext null + val byHour = sessions.groupingBy { it.hour }.eachCount() + val (hour, count) = byHour.maxByOrNull { it.value } ?: return@withContext null + if (count.toFloat() / sessions.size < 0.4f) null else hour + } + + // ---- streak math over streak_log (descending by date) ---- + private fun currentStreak(logs: List): Int { + if (logs.isEmpty()) return 0 + val byDate = logs.associateBy { it.dateIso } + var day = clock.today() + // allow today to be incomplete: start from today if practiced, else yesterday + if (byDate[day.toString()]?.let { it.practiced || it.frozen } != true) { + day = day.minusDays(1) + } + var count = 0 + while (true) { + val log = byDate[day.toString()] + if (log != null && (log.practiced || log.frozen)) { + if (log.practiced) count++ + day = day.minusDays(1) + } else break + } + return count + } + + private fun longestStreak(logs: List): Int { + val days = logs.filter { it.practiced || it.frozen } + .map { LocalDate.parse(it.dateIso) } + .sorted() + var best = 0 + var run = 0 + var prev: LocalDate? = null + for (d in days) { + run = if (prev != null && prev.plusDays(1) == d) run + 1 else 1 + best = maxOf(best, run) + prev = d + } + return best + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/repository/SettingsRepositoryImpl.kt b/slovko/app/src/main/java/com/slovko/data/repository/SettingsRepositoryImpl.kt new file mode 100644 index 0000000..5d673ed --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/repository/SettingsRepositoryImpl.kt @@ -0,0 +1,80 @@ +package com.slovko.data.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import com.slovko.data.datastore.PrefKeys +import com.slovko.domain.model.ThemeMode +import com.slovko.domain.model.UserSettings +import com.slovko.domain.repository.SettingsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SettingsRepositoryImpl @Inject constructor( + private val dataStore: DataStore, +) : SettingsRepository { + + override val settings: Flow = dataStore.data.map { it.toSettings() } + + override suspend fun current(): UserSettings = settings.first() + + override suspend fun update(transform: (UserSettings) -> UserSettings) { + dataStore.edit { prefs -> + val updated = transform(prefs.toSettings()) + prefs[PrefKeys.ONBOARDED] = updated.onboarded + prefs[PrefKeys.DAILY_GOAL] = updated.dailyGoalXp + prefs[PrefKeys.TARGET_RETENTION] = updated.targetRetention + prefs[PrefKeys.REMINDER_HOUR] = updated.reminderHour + prefs[PrefKeys.REMINDER_MINUTE] = updated.reminderMinute + prefs[PrefKeys.SMART_TIMING] = updated.smartTiming + prefs[PrefKeys.DAILY_REMINDER] = updated.dailyReminderOn + prefs[PrefKeys.STREAK_REMINDER] = updated.streakReminderOn + prefs[PrefKeys.REVIEW_REMINDER] = updated.reviewReminderOn + prefs[PrefKeys.REENGAGE] = updated.reengageOn + prefs[PrefKeys.QUIET_START] = updated.quietHoursStart + prefs[PrefKeys.QUIET_END] = updated.quietHoursEnd + prefs[PrefKeys.SOUND_FX] = updated.soundEffects + prefs[PrefKeys.AUTOPLAY] = updated.autoplayAudio + prefs[PrefKeys.SPEAKING] = updated.speakingEnabled + prefs[PrefKeys.CHALLENGE] = updated.challengeMode + prefs[PrefKeys.REDUCED_MOTION] = updated.reducedMotion + prefs[PrefKeys.THEME_MODE] = updated.themeMode.name + prefs[PrefKeys.DYNAMIC_COLOR] = updated.dynamicColor + prefs[PrefKeys.AI_ENDPOINT] = updated.aiEndpoint + prefs[PrefKeys.AI_KEY] = updated.aiApiKey + prefs[PrefKeys.AI_ENABLED] = updated.aiEnabled + } + } + + private fun Preferences.toSettings(): UserSettings { + val d = UserSettings() + return UserSettings( + onboarded = this[PrefKeys.ONBOARDED] ?: d.onboarded, + dailyGoalXp = this[PrefKeys.DAILY_GOAL] ?: d.dailyGoalXp, + targetRetention = this[PrefKeys.TARGET_RETENTION] ?: d.targetRetention, + reminderHour = this[PrefKeys.REMINDER_HOUR] ?: d.reminderHour, + reminderMinute = this[PrefKeys.REMINDER_MINUTE] ?: d.reminderMinute, + smartTiming = this[PrefKeys.SMART_TIMING] ?: d.smartTiming, + dailyReminderOn = this[PrefKeys.DAILY_REMINDER] ?: d.dailyReminderOn, + streakReminderOn = this[PrefKeys.STREAK_REMINDER] ?: d.streakReminderOn, + reviewReminderOn = this[PrefKeys.REVIEW_REMINDER] ?: d.reviewReminderOn, + reengageOn = this[PrefKeys.REENGAGE] ?: d.reengageOn, + quietHoursStart = this[PrefKeys.QUIET_START] ?: d.quietHoursStart, + quietHoursEnd = this[PrefKeys.QUIET_END] ?: d.quietHoursEnd, + soundEffects = this[PrefKeys.SOUND_FX] ?: d.soundEffects, + autoplayAudio = this[PrefKeys.AUTOPLAY] ?: d.autoplayAudio, + speakingEnabled = this[PrefKeys.SPEAKING] ?: d.speakingEnabled, + challengeMode = this[PrefKeys.CHALLENGE] ?: d.challengeMode, + reducedMotion = this[PrefKeys.REDUCED_MOTION] ?: d.reducedMotion, + themeMode = this[PrefKeys.THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() } ?: d.themeMode, + dynamicColor = this[PrefKeys.DYNAMIC_COLOR] ?: d.dynamicColor, + aiEndpoint = this[PrefKeys.AI_ENDPOINT] ?: d.aiEndpoint, + aiApiKey = this[PrefKeys.AI_KEY] ?: d.aiApiKey, + aiEnabled = this[PrefKeys.AI_ENABLED] ?: d.aiEnabled, + ) + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt b/slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt new file mode 100644 index 0000000..c438cc0 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt @@ -0,0 +1,91 @@ +package com.slovko.data.repository + +import com.slovko.core.common.AppClock +import com.slovko.core.common.IoDispatcher +import com.slovko.data.db.dao.ContentDao +import com.slovko.data.db.dao.SrsDao +import com.slovko.data.db.entity.SrsStateEntity +import com.slovko.data.mapper.toDomain +import com.slovko.data.mapper.toReviewCard +import com.slovko.domain.model.CardDirection +import com.slovko.domain.model.CardState +import com.slovko.domain.model.Grade +import com.slovko.domain.model.ReviewCard +import com.slovko.domain.repository.SettingsRepository +import com.slovko.domain.repository.SrsRepository +import com.slovko.domain.srs.FsrsParams +import com.slovko.domain.srs.FsrsScheduler +import com.slovko.domain.srs.SchedulingState +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SrsRepositoryImpl @Inject constructor( + private val srsDao: SrsDao, + private val contentDao: ContentDao, + private val settings: SettingsRepository, + private val clock: AppClock, + @IoDispatcher private val io: CoroutineDispatcher, +) : SrsRepository { + + override fun observeDueCount(): Flow = srsDao.observeDueCount(clock.nowMillis()) + + override suspend fun dueCount(): Int = srsDao.dueCount(clock.nowMillis()) + + override suspend fun dueCards(limit: Int): List = withContext(io) { + srsDao.dueStates(clock.nowMillis(), limit).mapNotNull { state -> + contentDao.getVocab(state.cardId)?.let { state.toReviewCard(it.toDomain()) } + } + } + + override suspend fun warmupCards(limit: Int): List = withContext(io) { + srsDao.reviewStates(limit).mapNotNull { state -> + contentDao.getVocab(state.cardId)?.let { state.toReviewCard(it.toDomain()) } + } + } + + override suspend fun grade(cardId: String, direction: CardDirection, grade: Grade) = withContext(io) { + val now = clock.nowMillis() + val retention = settings.current().targetRetention.toDouble() + val scheduler = FsrsScheduler(FsrsParams(requestRetention = retention)) + val existing = srsDao.getState(cardId, direction.value) + + val prev = existing?.let { + SchedulingState( + state = CardState.from(it.state), + stability = it.stability, + difficulty = it.difficulty, + dueMillis = it.due, + reps = it.reps, + lapses = it.lapses, + lastReviewMillis = it.lastReview, + ) + } ?: SchedulingState(CardState.NEW, 0.0, 0.0, now, 0, 0, null) + + val next = scheduler.review(prev, grade, now) + val entity = SrsStateEntity( + id = existing?.id ?: 0, + cardId = cardId, + direction = direction.value, + state = next.state.value, + stability = next.stability, + difficulty = next.difficulty, + due = next.dueMillis, + lastReview = next.lastReviewMillis, + reps = next.reps, + lapses = next.lapses, + ) + if (existing == null) srsDao.insert(entity) else srsDao.update(entity) + } + + override suspend fun bornCard(cardId: String, direction: CardDirection) = withContext(io) { + val existing = srsDao.getState(cardId, direction.value) + if (existing == null) { + // First correct production grades the card as Good immediately. + grade(cardId, direction, Grade.GOOD) + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/data/seed/ContentSeeder.kt b/slovko/app/src/main/java/com/slovko/data/seed/ContentSeeder.kt new file mode 100644 index 0000000..83959d9 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/seed/ContentSeeder.kt @@ -0,0 +1,108 @@ +package com.slovko.data.seed + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.room.withTransaction +import com.slovko.data.AppJson +import com.slovko.data.datastore.PrefKeys +import com.slovko.data.db.SlovkoDatabase +import com.slovko.data.db.entity.AchievementEntity +import com.slovko.data.db.entity.ExerciseEntity +import com.slovko.data.db.entity.LessonEntity +import com.slovko.data.db.entity.SkillEntity +import com.slovko.data.db.entity.VocabCardEntity +import com.slovko.data.mapper.toEntity +import com.slovko.domain.AchievementsCatalog +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Parses bundled curriculum JSON and seeds Room on first launch / version bump. + * Content tables only — never touches user/SRS/streak/profile. See DESIGN.md §13. + */ +@Singleton +class ContentSeeder @Inject constructor( + @ApplicationContext private val context: Context, + private val db: SlovkoDatabase, + private val dataStore: DataStore, +) { + private val dao get() = db.contentDao() + + suspend fun seedIfNeeded() { + val manifest = readAsset("curriculum/manifest.json") + ?.let { runCatching { AppJson.decodeFromString(ManifestDto.serializer(), it) }.getOrNull() } + ?: ManifestDto() + + val storedVersion = dataStore.data.first()[PrefKeys.SEED_VERSION] ?: 0 + val needsContent = storedVersion < manifest.version || dao.skillCount() == 0 + + if (needsContent) { + seedContent(manifest) + dataStore.edit { it[PrefKeys.SEED_VERSION] = manifest.version } + } + seedAchievements() + } + + private suspend fun seedContent(manifest: ManifestDto) { + val skills = mutableListOf() + val lessons = mutableListOf() + val exercises = mutableListOf() + val vocab = mutableListOf() + + for (file in manifest.skillFiles) { + val raw = readAsset("curriculum/$file") ?: continue + val unitFile = runCatching { + AppJson.decodeFromString(UnitFileDto.serializer(), raw) + }.getOrNull() ?: continue + + for (skill in unitFile.skills) { + skills += skill.toEntity(unitFile.unit) + skill.vocab.forEach { vocab += it.toEntity() } + for (lesson in skill.lessons) { + lessons += lesson.toEntity(skill.id) + lesson.exercises.forEach { exercises += it.toEntity(lesson.id) } + } + } + } + + val scenarios = manifest.scenarioFile + ?.let { readAsset("curriculum/$it") } + ?.let { runCatching { AppJson.decodeFromString(ScenarioFileDto.serializer(), it) }.getOrNull() } + ?.scenarios?.map { it.toEntity() } ?: emptyList() + + val phrases = manifest.phrasebookFile + ?.let { readAsset("curriculum/$it") } + ?.let { runCatching { AppJson.decodeFromString(PhrasebookFileDto.serializer(), it) }.getOrNull() } + ?.phrases?.map { it.toEntity() } ?: emptyList() + + db.withTransaction { + dao.clearExercises(); dao.clearLessons(); dao.clearVocab() + dao.clearScenarios(); dao.clearPhrases(); dao.clearSkills() + dao.insertSkills(skills) + dao.insertVocab(vocab) + dao.insertLessons(lessons) + dao.insertExercises(exercises) + dao.insertScenarios(scenarios) + dao.insertPhrases(phrases) + } + } + + private suspend fun seedAchievements() { + val items = AchievementsCatalog.ALL.mapIndexed { index, def -> + AchievementEntity( + id = def.id, title = def.title, description = def.description, + iconKey = def.iconKey, threshold = def.threshold, progress = 0, + unlockedAtEpoch = null, tier = def.tier, orderIndex = index, + ) + } + db.gamificationDao().insertAchievements(items) // IGNORE keeps existing progress + } + + private fun readAsset(path: String): String? = runCatching { + context.assets.open(path).bufferedReader().use { it.readText() } + }.getOrNull() +} diff --git a/slovko/app/src/main/java/com/slovko/data/seed/SeedDtos.kt b/slovko/app/src/main/java/com/slovko/data/seed/SeedDtos.kt new file mode 100644 index 0000000..f75d733 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/data/seed/SeedDtos.kt @@ -0,0 +1,134 @@ +package com.slovko.data.seed + +import kotlinx.serialization.Serializable + +/** @Serializable DTOs mirroring the bundled curriculum JSON. See DESIGN.md §13 / Appendix A. */ + +@Serializable +data class ManifestDto( + val version: Int = 1, + val skillFiles: List = emptyList(), + val scenarioFile: String? = null, + val phrasebookFile: String? = null, +) + +@Serializable +data class UnitFileDto( + val unit: UnitDto, + val skills: List = emptyList(), +) + +@Serializable +data class UnitDto( + val id: String, + val name: String, + val cefr: String = "A1", + val order: Int = 0, +) + +@Serializable +data class SkillDto( + val id: String, + val unitId: String, + val title: String, + val description: String = "", + val iconKey: String = "star", + val colorKey: String = "red", + val orderIndex: Int = 0, + val cefrLevel: String = "A1", + val vocab: List = emptyList(), + val lessons: List = emptyList(), +) + +@Serializable +data class VocabDto( + val id: String, + val sk: String, + val en: String, + val partOfSpeech: String = "word", + val ipa: String? = null, + val exampleSk: String? = null, + val exampleEn: String? = null, + val gender: String? = null, + val audioKey: String? = null, + val register: String = "neutral", + val frequencyRank: Int = 9999, +) + +@Serializable +data class LessonDto( + val id: String, + val title: String, + val orderIndex: Int = 0, + val type: String = "practice", + val xpReward: Int = 15, + val exercises: List = emptyList(), +) + +@Serializable +data class ExerciseDto( + val id: String, + val orderIndex: Int = 0, + val type: String, + val promptSk: String? = null, + val promptEn: String? = null, + val answer: String = "", + val acceptable: List = emptyList(), + val choices: List = emptyList(), + val pairs: List> = emptyList(), + val audioKey: String? = null, + val vocabCardId: String? = null, + val hint: String? = null, + val register: String = "neutral", + val caseTag: String? = null, + val aspectTag: String? = null, +) + +@Serializable +data class ScenarioFileDto( + val scenarios: List = emptyList(), +) + +@Serializable +data class ScenarioDto( + val id: String, + val title: String, + val description: String = "", + val cefrLevel: String = "A1", + val starterLineSk: String = "", + val iconKey: String = "chat", + val locked: Boolean = false, + val orderIndex: Int = 0, + val turns: List = emptyList(), +) + +@Serializable +data class TurnDto( + val role: String = "partner", + val sk: String, + val enGloss: String = "", + val replies: List = emptyList(), +) + +@Serializable +data class ReplyDto( + val sk: String, + val enGloss: String = "", + val natural: Boolean = false, +) + +@Serializable +data class PhrasebookFileDto( + val phrases: List = emptyList(), +) + +@Serializable +data class PhraseDto( + val id: String, + val sk: String, + val en: String, + val register: String = "informal", + val note: String? = null, + val vocabCardId: String? = null, + val orderIndex: Int = 0, +) diff --git a/slovko/app/src/main/java/com/slovko/di/BindingsModule.kt b/slovko/app/src/main/java/com/slovko/di/BindingsModule.kt new file mode 100644 index 0000000..d7a4ae4 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/di/BindingsModule.kt @@ -0,0 +1,52 @@ +package com.slovko.di + +import com.slovko.data.ai.ChatPartner +import com.slovko.data.ai.RemoteChatPartner +import com.slovko.data.audio.PronunciationPlayer +import com.slovko.data.audio.TtsManager +import com.slovko.data.repository.ChatRepositoryImpl +import com.slovko.data.repository.ContentRepositoryImpl +import com.slovko.data.repository.GamificationRepositoryImpl +import com.slovko.data.repository.ProgressRepositoryImpl +import com.slovko.data.repository.SettingsRepositoryImpl +import com.slovko.data.repository.SrsRepositoryImpl +import com.slovko.domain.repository.ChatRepository +import com.slovko.domain.repository.ContentRepository +import com.slovko.domain.repository.GamificationRepository +import com.slovko.domain.repository.ProgressRepository +import com.slovko.domain.repository.SettingsRepository +import com.slovko.domain.repository.SrsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class BindingsModule { + + @Binds @Singleton + abstract fun bindContentRepository(impl: ContentRepositoryImpl): ContentRepository + + @Binds @Singleton + abstract fun bindSrsRepository(impl: SrsRepositoryImpl): SrsRepository + + @Binds @Singleton + abstract fun bindProgressRepository(impl: ProgressRepositoryImpl): ProgressRepository + + @Binds @Singleton + abstract fun bindGamificationRepository(impl: GamificationRepositoryImpl): GamificationRepository + + @Binds @Singleton + abstract fun bindChatRepository(impl: ChatRepositoryImpl): ChatRepository + + @Binds @Singleton + abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository + + @Binds @Singleton + abstract fun bindPronunciationPlayer(impl: TtsManager): PronunciationPlayer + + @Binds @Singleton + abstract fun bindChatPartner(impl: RemoteChatPartner): ChatPartner +} diff --git a/slovko/app/src/main/java/com/slovko/di/ProvidersModule.kt b/slovko/app/src/main/java/com/slovko/di/ProvidersModule.kt new file mode 100644 index 0000000..5136262 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/di/ProvidersModule.kt @@ -0,0 +1,59 @@ +package com.slovko.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.room.Room +import com.slovko.core.common.AppClock +import com.slovko.core.common.DefaultDispatcher +import com.slovko.core.common.IoDispatcher +import com.slovko.core.common.SystemClock +import com.slovko.data.db.SlovkoDatabase +import com.slovko.data.db.dao.ChatDao +import com.slovko.data.db.dao.ContentDao +import com.slovko.data.db.dao.GamificationDao +import com.slovko.data.db.dao.ProgressDao +import com.slovko.data.db.dao.SrsDao +import com.slovko.domain.league.LeagueEngine +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ProvidersModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): SlovkoDatabase = + Room.databaseBuilder(context, SlovkoDatabase::class.java, SlovkoDatabase.NAME) + .fallbackToDestructiveMigration() + .build() + + @Provides fun contentDao(db: SlovkoDatabase): ContentDao = db.contentDao() + @Provides fun srsDao(db: SlovkoDatabase): SrsDao = db.srsDao() + @Provides fun progressDao(db: SlovkoDatabase): ProgressDao = db.progressDao() + @Provides fun gamificationDao(db: SlovkoDatabase): GamificationDao = db.gamificationDao() + @Provides fun chatDao(db: SlovkoDatabase): ChatDao = db.chatDao() + + @Provides + @Singleton + fun provideDataStore(@ApplicationContext context: Context): DataStore = + PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("slovko_settings") }, + ) + + @Provides @Singleton fun provideClock(): AppClock = SystemClock() + + @Provides @Singleton fun provideLeagueEngine(): LeagueEngine = LeagueEngine() + + @Provides @IoDispatcher fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + @Provides @DefaultDispatcher fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default +} diff --git a/slovko/app/src/main/java/com/slovko/domain/AchievementsCatalog.kt b/slovko/app/src/main/java/com/slovko/domain/AchievementsCatalog.kt new file mode 100644 index 0000000..26fe5a1 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/AchievementsCatalog.kt @@ -0,0 +1,32 @@ +package com.slovko.domain + +/** The 15 achievements (DESIGN.md §7). Seeded once; progress tracked at runtime. */ +object AchievementsCatalog { + + data class Def( + val id: String, + val title: String, + val description: String, + val iconKey: String, + val threshold: Int, + val tier: Int, + ) + + val ALL: List = listOf( + Def("prve_slovo", "Prvé slovo", "Finish your very first lesson.", "spark", 1, 1), + Def("ranne_vtaca", "Ranné vtáča", "Practice before 9am.", "sunrise", 1, 1), + Def("nocna_sova", "Nočná sova", "Practice after 10pm.", "moon", 1, 1), + Def("bezchybny", "Bezchybný", "Complete 25 perfect lessons.", "target", 25, 2), + Def("ohnivak", "Ohnivák", "Reach a 30-day streak.", "flame", 30, 2), + Def("ukecany", "Ukecaný", "Finish 10 chat conversations.", "chat", 10, 2), + Def("dobre_ucho", "Dobré ucho", "Get 100 listening exercises right.", "ear", 100, 2), + Def("hlas_naroda", "Hlas národa", "Complete 50 speaking exercises.", "mic", 50, 2), + Def("slovnik", "Slovník", "Learn 500 words.", "book", 500, 3), + Def("maratonec", "Maratónec", "Earn 5000 total XP.", "run", 5000, 3), + Def("tyzden_vitazstva", "Týždeň víťazstva", "Hit your daily goal 7 days in a row.", "trophy", 7, 2), + Def("povyseny", "Povýšený", "Get promoted in a league.", "up", 1, 2), + Def("verny", "Verný", "Reach a 100-day streak.", "heart", 100, 3), + Def("zberatel", "Zberateľ", "Collect 1000 gems.", "gem", 1000, 2), + Def("kamarat", "Kamarát", "Finish the Chat-with-Friends unit.", "friends", 1, 3), + ) +} diff --git a/slovko/app/src/main/java/com/slovko/domain/GamificationConfig.kt b/slovko/app/src/main/java/com/slovko/domain/GamificationConfig.kt new file mode 100644 index 0000000..b05dcac --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/GamificationConfig.kt @@ -0,0 +1,92 @@ +package com.slovko.domain + +import com.slovko.domain.model.ExerciseType + +/** + * Single source of all tunable gamification constants. See DESIGN.md §7. + * Balance the app by editing numbers here — logic never hardcodes them. + */ +object GamificationConfig { + + object Xp { + const val LESSON_COMPLETE_BONUS = 10 + const val PERFECT_LESSON_BONUS = 15 + const val FAST_LESSON_BONUS = 8 + const val COMBO_STEP = 1 + const val COMBO_THRESHOLD = 3 // combo starts after this many in a row + const val COMBO_MAX_BONUS = 10 + const val FIRST_LESSON_OF_DAY = 5 + const val SRS_REVIEW = 5 + const val CHAT_TURN = 12 + + fun baseFor(type: ExerciseType): Int = when (type) { + ExerciseType.WORD_BANK, ExerciseType.TRANSLATE_EN_SK, ExerciseType.TRANSLATE_SK_EN, + ExerciseType.MCQ, ExerciseType.MATCH_PAIRS, ExerciseType.FILL_CASE, + ExerciseType.ASPECT_CHOICE, ExerciseType.DIALOGUE_FILL -> 6 + ExerciseType.LISTEN_CHOOSE -> 8 + ExerciseType.LISTEN_TYPE -> 9 + ExerciseType.SPEAK -> 10 + } + } + + object Streak { + const val GRACE_HOURS = 3 + const val FREEZE_MAX_HELD = 2 + const val FREEZE_GEM_COST = 200 + const val REPAIR_WINDOW_DAYS = 2 + const val REPAIR_GEM_COST = 350 + const val WEEKEND_AMULET_COST = 100 + val MILESTONES = listOf(3, 7, 14, 30, 50, 100, 365) + fun milestoneRewardGems(day: Int): Int = when (day) { + 3 -> 20; 7 -> 40; 14 -> 60; 30 -> 120; 50 -> 200; 100 -> 400; 365 -> 1000 + else -> 0 + } + } + + object Mistake { + const val REQUEUE_AFTER_N_ITEMS = 3 + const val MAX_REQUEUE = 2 + const val CHALLENGE_MODE_HEARTS = 3 + const val CHALLENGE_MODE_XP_MULT = 1.5 + } + + object League { + const val COHORT_SIZE = 15 // user + 14 bots + const val PROMOTE_TOP = 5 + const val DEMOTE_BOTTOM = 4 + const val MIN_BOT_MEAN = 20 + const val MEAN_FACTOR_MIN = 0.55 + const val MEAN_FACTOR_MAX = 1.35 + const val WEEK_DAYS = 7 + } + + /** Daily XP goal tiers (label → goal XP). */ + val DAILY_GOAL_TIERS = listOf( + "Casual" to 30, + "Regular" to 60, + "Serious" to 120, + "Intense" to 250, + ) + const val DEFAULT_DAILY_GOAL = 60 + + // ---- Level curve: cumulative XP to *reach* level n. xp(n) = 50n^2 + 50n ---- + fun cumulativeXpForLevel(level: Int): Int { + if (level <= 1) return 0 + val n = level - 1 + return 50 * n * n + 50 * n + } + + fun levelForXp(totalXp: Int): Int { + var level = 1 + while (cumulativeXpForLevel(level + 1) <= totalXp) level++ + return level + } + + /** XP earned within the current level and XP needed to span the current level. */ + fun levelProgress(totalXp: Int): Pair { + val level = levelForXp(totalXp) + val base = cumulativeXpForLevel(level) + val next = cumulativeXpForLevel(level + 1) + return (totalXp - base) to (next - base) + } +} diff --git a/slovko/app/src/main/java/com/slovko/domain/league/LeagueEngine.kt b/slovko/app/src/main/java/com/slovko/domain/league/LeagueEngine.kt new file mode 100644 index 0000000..e16b465 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/league/LeagueEngine.kt @@ -0,0 +1,96 @@ +package com.slovko.domain.league + +import com.slovko.domain.GamificationConfig.League as Cfg +import com.slovko.domain.model.League +import com.slovko.domain.model.LeagueStanding +import com.slovko.domain.model.LeagueTier +import java.util.Random +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Backend-free, deterministic, honest league. A cohort of 14 bots is generated + * each week clustered around the user's trailing average so the race is always + * winnable. Bot XP accrues lazily from elapsed time. See DESIGN.md §7. + */ +class LeagueEngine { + + data class Bot(val name: String, val avatarKey: String, val dailyMean: Int, val variance: Int) + + private val names = listOf( + "Mária", "Jakub", "Sofia", "Adam", "Nina", "Marek", "Ema", "Tomáš", + "Laura", "Filip", "Hana", "Lukáš", "Viktória", "Martin", "Zuzka", + "Peter", "Klára", "Samo", "Dáša", "Oliver", "Terka", "Matej", + ) + private val avatars = listOf("fox", "bear", "owl", "deer", "hedgehog", "wolf", "lynx") + + /** Deterministic per-week seed from the ISO week id (e.g. "2026-W26"). */ + private fun seedFor(weekId: String): Long = weekId.hashCode().toLong() * 2654435761L + + fun generateBots(weekId: String, userTrailingDailyAvg: Int): List { + val rng = Random(seedFor(weekId)) + val baseline = max(Cfg.MIN_BOT_MEAN, userTrailingDailyAvg) + return (0 until Cfg.COHORT_SIZE - 1).map { i -> + val factor = Cfg.MEAN_FACTOR_MIN + + rng.nextDouble() * (Cfg.MEAN_FACTOR_MAX - Cfg.MEAN_FACTOR_MIN) + val mean = max(Cfg.MIN_BOT_MEAN, (baseline * factor).roundToInt()) + Bot( + name = names[(seedFor(weekId).toInt() + i).mod(names.size)], + avatarKey = avatars[(i + weekId.length).mod(avatars.size)], + dailyMean = mean, + variance = max(5, mean / 4), + ) + } + } + + /** Bot cumulative XP after [daysElapsed] (fractional) of the week. */ + fun botXp(weekId: String, bot: Bot, botIndex: Int, daysElapsed: Double): Int { + val rng = Random(seedFor(weekId) xor (botIndex.toLong() * 1_000_003L)) + var total = 0.0 + val fullDays = daysElapsed.toInt() + for (d in 0 until fullDays) { + val jitter = (rng.nextDouble() * 2 - 1) * bot.variance + total += max(0.0, bot.dailyMean + jitter) + } + // partial current day + val frac = daysElapsed - fullDays + total += max(0.0, bot.dailyMean * frac) + return total.roundToInt() + } + + fun standings( + weekId: String, + tier: LeagueTier, + userXp: Int, + userName: String, + userAvatar: String, + userTrailingDailyAvg: Int, + daysElapsed: Double, + daysRemaining: Int, + ): League { + val bots = generateBots(weekId, userTrailingDailyAvg) + val rows = buildList { + add(Triple(userName, userXp, true) to userAvatar) + bots.forEachIndexed { i, bot -> + add(Triple(bot.name, botXp(weekId, bot, i, daysElapsed), false) to bot.avatarKey) + } + }.sortedByDescending { it.first.second } + + val standings = rows.mapIndexed { idx, (triple, avatar) -> + LeagueStanding( + rank = idx + 1, + name = triple.first, + xp = triple.second, + isUser = triple.third, + avatarKey = avatar, + ) + } + return League( + tier = tier, + daysRemaining = daysRemaining, + standings = standings, + promoteCutoff = Cfg.PROMOTE_TOP, + demoteCutoff = Cfg.COHORT_SIZE - Cfg.DEMOTE_BOTTOM, + ) + } +} diff --git a/slovko/app/src/main/java/com/slovko/domain/model/DomainModels.kt b/slovko/app/src/main/java/com/slovko/domain/model/DomainModels.kt new file mode 100644 index 0000000..40612a2 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/model/DomainModels.kt @@ -0,0 +1,215 @@ +package com.slovko.domain.model + +/** Pure-Kotlin domain models. No Android / Room annotations. */ + +data class CurriculumUnit( + val id: String, + val name: String, + val cefr: String, + val order: Int, + val skills: List, +) + +data class Skill( + val id: String, + val unitId: String, + val title: String, + val description: String, + val iconKey: String, + val colorKey: String, + val orderIndex: Int, + val cefrLevel: String, + val lessons: List = emptyList(), +) + +data class Lesson( + val id: String, + val skillId: String, + val title: String, + val orderIndex: Int, + val type: LessonType, + val xpReward: Int, + val exercises: List = emptyList(), +) + +data class Exercise( + val id: String, + val lessonId: String, + val orderIndex: Int, + val type: ExerciseType, + val promptSk: String?, + val promptEn: String?, + val answer: String, + val acceptable: List, + val choices: List, + val pairs: List> = emptyList(), + val audioKey: String? = null, + val vocabCardId: String? = null, + val hint: String? = null, + val caseTag: CaseTag? = null, + val aspectTag: AspectTag? = null, + val register: Register = Register.NEUTRAL, +) + +data class VocabCard( + val id: String, + val sk: String, + val en: String, + val partOfSpeech: String, + val ipa: String? = null, + val exampleSk: String? = null, + val exampleEn: String? = null, + val gender: String? = null, + val audioKey: String? = null, + val register: Register = Register.NEUTRAL, + val frequencyRank: Int = 9999, +) + +// ---- Chat ---- +data class ChatScenario( + val id: String, + val title: String, + val description: String, + val cefrLevel: String, + val starterLineSk: String, + val iconKey: String, + val locked: Boolean, + val turns: List = emptyList(), +) + +data class ScenarioTurn( + val role: String, // "partner" | "user" + val sk: String, + val enGloss: String, + val replies: List = emptyList(), +) + +data class ReplyOption( + val sk: String, + val enGloss: String, + val natural: Boolean, +) + +data class ChatMessage( + val id: Long = 0, + val scenarioId: String, + val role: String, // "partner" | "user" + val textSk: String, + val textEnGloss: String? = null, + val createdAt: Long = 0, +) + +data class Phrase( + val id: String, + val sk: String, + val en: String, + val register: Register, + val note: String? = null, + val vocabCardId: String? = null, +) + +// ---- Progress / gamification ---- +data class UserStats( + val displayName: String, + val level: Int, + val totalXp: Int, + val xpIntoLevel: Int, + val xpForNextLevel: Int, + val gems: Int, + val crowns: Int, + val currentStreak: Int, + val longestStreak: Int, + val wordsLearned: Int, + val avatarKey: String, + val memberSinceEpochDay: Long, +) + +data class DailyGoal( + val dateIso: String, + val goalXp: Int, + val earnedXp: Int, + val lessonsCompleted: Int, + val met: Boolean, +) { + val progress: Float get() = if (goalXp <= 0) 0f else (earnedXp.toFloat() / goalXp).coerceIn(0f, 1f) +} + +data class Quest( + val id: String, + val title: String, + val target: Int, + val progress: Int, + val rewardGems: Int, + val completed: Boolean, +) { + val fraction: Float get() = if (target <= 0) 0f else (progress.toFloat() / target).coerceIn(0f, 1f) +} + +data class Achievement( + val id: String, + val title: String, + val description: String, + val iconKey: String, + val threshold: Int, + val progress: Int, + val unlockedAtEpoch: Long?, + val tier: Int, +) { + val unlocked: Boolean get() = unlockedAtEpoch != null + val fraction: Float get() = if (threshold <= 0) 0f else (progress.toFloat() / threshold).coerceIn(0f, 1f) +} + +data class LeagueStanding( + val rank: Int, + val name: String, + val xp: Int, + val isUser: Boolean, + val avatarKey: String, +) + +data class League( + val tier: LeagueTier, + val daysRemaining: Int, + val standings: List, + val promoteCutoff: Int, // ranks <= this promote + val demoteCutoff: Int, // ranks > this demote +) + +// ---- Lesson runtime ---- +data class FocusState( + val mode: FocusMode, + val heartsRemaining: Int, +) + +data class Feedback( + val correct: Boolean, + val title: String, + val correctAnswer: String, + val explanation: String? = null, +) + +/** A day in the activity heatmap. */ +data class HeatDay( + val epochDay: Long, + val xp: Int, + val met: Boolean, +) + +/** Result of grading a single exercise answer. */ +data class GradeResult( + val correct: Boolean, + val grade: Grade, +) + +/** A card with its current SRS scheduling state. */ +data class ReviewCard( + val card: VocabCard, + val direction: CardDirection, + val state: CardState, + val stability: Double, + val difficulty: Double, + val due: Long, + val reps: Int, + val lapses: Int, + val lastReviewMillis: Long?, +) diff --git a/slovko/app/src/main/java/com/slovko/domain/model/Enums.kt b/slovko/app/src/main/java/com/slovko/domain/model/Enums.kt new file mode 100644 index 0000000..9c6c0b9 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/model/Enums.kt @@ -0,0 +1,71 @@ +package com.slovko.domain.model + +enum class ExerciseType { + MCQ, + TRANSLATE_SK_EN, + TRANSLATE_EN_SK, + LISTEN_CHOOSE, + LISTEN_TYPE, + WORD_BANK, + SPEAK, + MATCH_PAIRS, + FILL_CASE, + ASPECT_CHOICE, + DIALOGUE_FILL, +} + +enum class LessonType { TEACH, PRACTICE, CHECKPOINT } + +enum class Register { INFORMAL, NEUTRAL, FORMAL } + +enum class CaseTag { NOM, ACC, LOC, INS, GEN, DAT } + +enum class AspectTag { IMPF, PERF } + +/** FSRS review grade. Value matches FSRS rating index. */ +enum class Grade(val value: Int) { + AGAIN(1), + HARD(2), + GOOD(3), + EASY(4), +} + +enum class CardState(val value: Int) { + NEW(0), + LEARNING(1), + REVIEW(2), + RELEARNING(3); + + companion object { + fun from(value: Int): CardState = entries.firstOrNull { it.value == value } ?: NEW + } +} + +/** Direction of a vocab card for SRS granularity. */ +enum class CardDirection(val value: Int) { + SK_TO_EN(0), // recognition + EN_TO_SK(1); // production + + companion object { + fun from(value: Int): CardDirection = entries.firstOrNull { it.value == value } ?: SK_TO_EN + } +} + +enum class LeagueTier(val displayName: String) { + BRONZE("Bronze"), + SILVER("Silver"), + GOLD("Gold"), + SAPPHIRE("Sapphire"), + RUBY("Ruby"), + EMERALD("Emerald"), + DIAMOND("Diamond"); + + fun promote(): LeagueTier = entries.getOrElse(ordinal + 1) { this } + fun demote(): LeagueTier = entries.getOrElse(ordinal - 1) { this } +} + +enum class ChestRarity { COMMON, RARE, EPIC } + +enum class FocusMode { FOCUS, CHALLENGE } + +enum class MajaPose { WAVING, THINKING, CELEBRATING, SLEEPY, PROUD, WORRIED, LISTENING, READING } diff --git a/slovko/app/src/main/java/com/slovko/domain/model/Settings.kt b/slovko/app/src/main/java/com/slovko/domain/model/Settings.kt new file mode 100644 index 0000000..c43d1c2 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/model/Settings.kt @@ -0,0 +1,28 @@ +package com.slovko.domain.model + +enum class ThemeMode { SYSTEM, LIGHT, DARK } + +data class UserSettings( + val onboarded: Boolean = false, + val dailyGoalXp: Int = 60, + val targetRetention: Float = 0.90f, + val reminderHour: Int = 19, + val reminderMinute: Int = 0, + val smartTiming: Boolean = true, + val dailyReminderOn: Boolean = true, + val streakReminderOn: Boolean = true, + val reviewReminderOn: Boolean = true, + val reengageOn: Boolean = true, + val quietHoursStart: Int = 22, + val quietHoursEnd: Int = 7, + val soundEffects: Boolean = true, + val autoplayAudio: Boolean = true, + val speakingEnabled: Boolean = true, + val challengeMode: Boolean = false, + val reducedMotion: Boolean = false, + val themeMode: ThemeMode = ThemeMode.SYSTEM, + val dynamicColor: Boolean = true, + val aiEndpoint: String = "", + val aiApiKey: String = "", + val aiEnabled: Boolean = false, +) diff --git a/slovko/app/src/main/java/com/slovko/domain/repository/Repositories.kt b/slovko/app/src/main/java/com/slovko/domain/repository/Repositories.kt new file mode 100644 index 0000000..8930cce --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/repository/Repositories.kt @@ -0,0 +1,91 @@ +package com.slovko.domain.repository + +import com.slovko.domain.model.Achievement +import com.slovko.domain.model.ChatMessage +import com.slovko.domain.model.ChatScenario +import com.slovko.domain.model.CardDirection +import com.slovko.domain.model.CurriculumUnit +import com.slovko.domain.model.DailyGoal +import com.slovko.domain.model.Grade +import com.slovko.domain.model.HeatDay +import com.slovko.domain.model.League +import com.slovko.domain.model.Lesson +import com.slovko.domain.model.Phrase +import com.slovko.domain.model.Quest +import com.slovko.domain.model.ReviewCard +import com.slovko.domain.model.Skill +import com.slovko.domain.model.UserSettings +import com.slovko.domain.model.UserStats +import com.slovko.domain.model.VocabCard +import kotlinx.coroutines.flow.Flow + +interface ContentRepository { + suspend fun ensureSeeded() + fun observeUnits(): Flow> + suspend fun getLesson(lessonId: String): Lesson? + suspend fun getSkill(skillId: String): Skill? + fun observeScenarios(): Flow> + suspend fun getScenario(scenarioId: String): ChatScenario? + fun observePhrases(): Flow> + suspend fun getVocabCard(cardId: String): VocabCard? + suspend fun wordOfTheDay(): VocabCard? +} + +interface ProgressRepository { + suspend fun ensureProfile() + fun observeUserStats(): Flow + fun observeDailyGoal(): Flow + fun observeHeatmap(days: Int): Flow> + fun observeCompletedLessons(): Flow> + suspend fun completedLessonIds(): Set + /** Records a finished lesson; returns the resulting streak delta (+1, 0). */ + suspend fun recordLessonCompletion( + lessonId: String, + xpEarned: Int, + accuracy: Float, + durationMs: Long, + newWords: Int, + ): Int + suspend fun addXp(amount: Int) + suspend fun setDisplayName(name: String) + suspend fun hasPracticedToday(): Boolean + /** Modal practice hour over last [days] from session logs, or null if too few. */ + suspend fun modalPracticeHour(days: Int): Int? +} + +interface SrsRepository { + fun observeDueCount(): Flow + suspend fun dueCount(): Int + suspend fun dueCards(limit: Int): List + suspend fun warmupCards(limit: Int): List + suspend fun grade(cardId: String, direction: CardDirection, grade: Grade) + /** Create SRS state the first time a card is correctly produced. */ + suspend fun bornCard(cardId: String, direction: CardDirection) +} + +interface GamificationRepository { + fun observeQuests(): Flow> + suspend fun rollDailyQuestsIfNeeded() + suspend fun progressQuests(xp: Int, lessons: Int, perfect: Boolean) + fun observeAchievements(): Flow> + /** Re-evaluate criteria; returns newly unlocked achievements. */ + suspend fun evaluateAchievements(): List + fun observeLeague(): Flow + suspend fun refreshLeague() + suspend fun addGems(amount: Int) + suspend fun spendGems(amount: Int): Boolean +} + +interface ChatRepository { + fun observeMessages(scenarioId: String): Flow> + suspend fun startIfEmpty(scenarioId: String) + suspend fun sendUserMessage(scenarioId: String, textSk: String, glossEn: String?) + suspend fun requestPartnerReply(scenarioId: String): Result + suspend fun reset(scenarioId: String) +} + +interface SettingsRepository { + val settings: Flow + suspend fun update(transform: (UserSettings) -> UserSettings) + suspend fun current(): UserSettings +} diff --git a/slovko/app/src/main/java/com/slovko/domain/srs/FsrsParams.kt b/slovko/app/src/main/java/com/slovko/domain/srs/FsrsParams.kt new file mode 100644 index 0000000..3835534 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/srs/FsrsParams.kt @@ -0,0 +1,36 @@ +package com.slovko.domain.srs + +/** + * FSRS-4.5 parameters. Pure data; no Android. See DESIGN.md §4. + * Weights are the published FSRS-4.5 defaults (good general-purpose prior). + */ +data class FsrsParams( + val w: DoubleArray = DEFAULT_W, + val requestRetention: Double = 0.90, // user-adjustable 0.85..0.95 + val maximumIntervalDays: Double = 3650.0, + val learningStepsMinutes: List = listOf(1, 10), + val relearningStepMinutes: Int = 10, +) { + val decay: Double get() = -0.5 + val factor: Double get() = Math.pow(0.9, 1.0 / decay) - 1.0 // = 19/81 + + companion object { + val DEFAULT_W = doubleArrayOf( + 0.4072, 1.1829, 3.1262, 15.4722, 7.2102, 0.5316, 1.0651, 0.0234, + 1.616, 0.1544, 1.0824, 1.9813, 0.0953, 0.2975, 2.2042, 0.2407, + 2.9466, 0.5034, 0.6567, + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is FsrsParams) return false + return w.contentEquals(other.w) && + requestRetention == other.requestRetention && + maximumIntervalDays == other.maximumIntervalDays && + learningStepsMinutes == other.learningStepsMinutes && + relearningStepMinutes == other.relearningStepMinutes + } + + override fun hashCode(): Int = w.contentHashCode() * 31 + requestRetention.hashCode() +} diff --git a/slovko/app/src/main/java/com/slovko/domain/srs/FsrsScheduler.kt b/slovko/app/src/main/java/com/slovko/domain/srs/FsrsScheduler.kt new file mode 100644 index 0000000..af20771 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/srs/FsrsScheduler.kt @@ -0,0 +1,174 @@ +package com.slovko.domain.srs + +import com.slovko.domain.model.CardState +import com.slovko.domain.model.Grade +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** Snapshot of a card's scheduling state fed into / out of the scheduler. */ +data class SchedulingState( + val state: CardState, + val stability: Double, + val difficulty: Double, + val dueMillis: Long, + val reps: Int, + val lapses: Int, + val lastReviewMillis: Long?, +) + +private const val MILLIS_PER_DAY = 86_400_000.0 +private const val MILLIS_PER_MIN = 60_000L + +/** + * Pure-Kotlin FSRS-4.5 scheduler. Deterministic function of (state, grade, time) + * → next state. Fully offline. See DESIGN.md §4. + */ +class FsrsScheduler(private val params: FsrsParams = FsrsParams()) { + + private val w get() = params.w + + /** Probability of recall after [elapsedDays] given [stability]. */ + fun retrievability(elapsedDays: Double, stability: Double): Double { + if (stability <= 0.0) return 0.0 + return (1.0 + params.factor * elapsedDays / stability).pow(params.decay) + } + + /** Days until retrievability decays to the requested retention. */ + fun intervalDays(stability: Double): Double { + val raw = (stability / params.factor) * + (params.requestRetention.pow(1.0 / params.decay) - 1.0) + return raw.coerceIn(1.0, params.maximumIntervalDays) + } + + fun review(prev: SchedulingState, grade: Grade, nowMillis: Long): SchedulingState { + return if (prev.state == CardState.NEW) { + firstReview(prev, grade, nowMillis) + } else { + subsequentReview(prev, grade, nowMillis) + } + } + + private fun firstReview(prev: SchedulingState, grade: Grade, now: Long): SchedulingState { + val stability = initialStability(grade) + val difficulty = initialDifficulty(grade) + return graduateOrStep( + prev = prev, + grade = grade, + now = now, + stability = stability, + difficulty = difficulty, + relearning = false, + incLapse = false, + ) + } + + private fun subsequentReview(prev: SchedulingState, grade: Grade, now: Long): SchedulingState { + val elapsedDays = if (prev.lastReviewMillis != null) { + max(0.0, (now - prev.lastReviewMillis) / MILLIS_PER_DAY) + } else 0.0 + val r = retrievability(elapsedDays, prev.stability) + val difficulty = nextDifficulty(prev.difficulty, grade) + + return if (grade == Grade.AGAIN) { + val newStability = postLapseStability(prev.difficulty, prev.stability, r) + graduateOrStep(prev, grade, now, newStability, difficulty, relearning = true, incLapse = true) + } else { + val newStability = nextRecallStability(prev.difficulty, prev.stability, r, grade) + graduateOrStep(prev, grade, now, newStability, difficulty, relearning = false, incLapse = false) + } + } + + private fun graduateOrStep( + prev: SchedulingState, + grade: Grade, + now: Long, + stability: Double, + difficulty: Double, + relearning: Boolean, + incLapse: Boolean, + ): SchedulingState { + val reps = prev.reps + 1 + val lapses = prev.lapses + if (incLapse) 1 else 0 + + // AGAIN / HARD on a not-yet-graduated card → short learning step. + val useShortStep = grade == Grade.AGAIN || + (grade == Grade.HARD && prev.state != CardState.REVIEW) + + return if (useShortStep) { + val stepMin = when { + relearning -> params.relearningStepMinutes + grade == Grade.AGAIN -> params.learningStepsMinutes.first() + else -> params.learningStepsMinutes.last() + } + SchedulingState( + state = if (relearning) CardState.RELEARNING else CardState.LEARNING, + stability = stability, + difficulty = difficulty, + dueMillis = now + stepMin * MILLIS_PER_MIN, + reps = reps, + lapses = lapses, + lastReviewMillis = now, + ) + } else { + val days = intervalDays(stability) + SchedulingState( + state = CardState.REVIEW, + stability = stability, + difficulty = difficulty, + dueMillis = now + (days * MILLIS_PER_DAY).toLong(), + reps = reps, + lapses = lapses, + lastReviewMillis = now, + ) + } + } + + // ---- FSRS math ---- + private fun initialStability(grade: Grade): Double = + max(0.1, w[grade.value - 1]) + + private fun initialDifficulty(grade: Grade): Double = + (w[4] - exp(w[5] * (grade.value - 1)) + 1.0).clampDifficulty() + + private fun nextDifficulty(d: Double, grade: Grade): Double { + val next = d - w[6] * (grade.value - 3) + // mean reversion toward initial "Good" difficulty + val meanReverted = w[7] * initialDifficulty(Grade.GOOD) + (1 - w[7]) * next + return meanReverted.clampDifficulty() + } + + private fun nextRecallStability(d: Double, s: Double, r: Double, grade: Grade): Double { + val hardPenalty = if (grade == Grade.HARD) w[15] else 1.0 + val easyBonus = if (grade == Grade.EASY) w[16] else 1.0 + val growth = exp(w[8]) * + (11 - d) * + s.pow(-w[9]) * + (exp(w[10] * (1 - r)) - 1) * + hardPenalty * + easyBonus + return (s * (1 + growth)).coerceIn(0.1, params.maximumIntervalDays) + } + + private fun postLapseStability(d: Double, s: Double, r: Double): Double { + val newS = w[11] * + d.pow(-w[12]) * + ((s + 1).pow(w[13]) - 1) * + exp(w[14] * (1 - r)) + return min(s, max(0.1, newS)) + } + + private fun Double.clampDifficulty(): Double = coerceIn(1.0, 10.0) +} + +/** Maps a grade history of n reviews to a readable interval string (debug helper). */ +fun daysToHuman(days: Double): String = when { + days < 1 -> "<1d" + days < 30 -> "${days.toInt()}d" + days < 365 -> "${(days / 30).toInt()}mo" + else -> "${(days / 365).let { "%.1f".format(it) }}y" +} + +internal fun lnSafe(x: Double): Double = if (x <= 0) 0.0 else ln(x) diff --git a/slovko/app/src/main/java/com/slovko/domain/usecase/GradeAnswerUseCase.kt b/slovko/app/src/main/java/com/slovko/domain/usecase/GradeAnswerUseCase.kt new file mode 100644 index 0000000..3d7d4b2 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/usecase/GradeAnswerUseCase.kt @@ -0,0 +1,48 @@ +package com.slovko.domain.usecase + +import com.slovko.core.common.TextNormalizer +import com.slovko.domain.model.Exercise +import com.slovko.domain.model.ExerciseType +import com.slovko.domain.model.Grade +import com.slovko.domain.model.GradeResult +import javax.inject.Inject + +/** + * Grades a typed/selected answer against an exercise's acceptable set. + * Diacritic- and word-order-tolerant except for LISTEN_TYPE (strict on diacritics). + * See DESIGN.md §5. + */ +class GradeAnswerUseCase @Inject constructor() { + + operator fun invoke( + exercise: Exercise, + userAnswer: String, + hintUsed: Boolean = false, + ): GradeResult { + val correct = isCorrect(exercise, userAnswer) + val grade = when { + !correct -> Grade.AGAIN + hintUsed -> Grade.HARD + else -> Grade.GOOD + } + return GradeResult(correct = correct, grade = grade) + } + + fun isCorrect(exercise: Exercise, userAnswer: String): Boolean { + val candidates = (exercise.acceptable + exercise.answer) + return when (exercise.type) { + ExerciseType.LISTEN_TYPE -> { + val ans = TextNormalizer.normalizeStrict(userAnswer) + candidates.any { TextNormalizer.normalizeStrict(it) == ans } || + candidates.any { TextNormalizer.normalize(it) == TextNormalizer.normalize(userAnswer) } + } + ExerciseType.WORD_BANK, ExerciseType.TRANSLATE_EN_SK, ExerciseType.TRANSLATE_SK_EN -> { + candidates.any { TextNormalizer.sameWords(it, userAnswer) } + } + else -> { + val ans = TextNormalizer.normalize(userAnswer) + candidates.any { TextNormalizer.normalize(it) == ans } + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/domain/usecase/XpCalculator.kt b/slovko/app/src/main/java/com/slovko/domain/usecase/XpCalculator.kt new file mode 100644 index 0000000..e37cfa9 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/domain/usecase/XpCalculator.kt @@ -0,0 +1,61 @@ +package com.slovko.domain.usecase + +import com.slovko.domain.GamificationConfig +import com.slovko.domain.GamificationConfig.Xp +import com.slovko.domain.model.ExerciseType + +/** Pure lesson-XP computation incl. combo & bonuses. See DESIGN.md §7. */ +object XpCalculator { + + data class Breakdown( + val base: Int, + val combo: Int, + val lessonBonus: Int, + val perfectBonus: Int, + val fastBonus: Int, + val firstOfDayBonus: Int, + val challengeMultiplier: Double, + ) { + val total: Int + get() = (((base + combo) * challengeMultiplier).toInt()) + + lessonBonus + perfectBonus + fastBonus + firstOfDayBonus + } + + /** + * @param results per-exercise (type, correct) in answer order. + * @param fast whether the lesson finished under the fast threshold. + * @param firstOfDay whether this is the first lesson today. + * @param challenge Challenge Mode (1.5× on earned XP). + */ + fun lessonXp( + results: List>, + fast: Boolean, + firstOfDay: Boolean, + challenge: Boolean, + ): Breakdown { + var base = 0 + var combo = 0 + var streak = 0 + for ((type, correct) in results) { + if (correct) { + base += Xp.baseFor(type) + streak++ + if (streak > Xp.COMBO_THRESHOLD) { + combo = (combo + Xp.COMBO_STEP).coerceAtMost(Xp.COMBO_MAX_BONUS) + } + } else { + streak = 0 + } + } + val perfect = results.isNotEmpty() && results.all { it.second } + return Breakdown( + base = base, + combo = combo, + lessonBonus = Xp.LESSON_COMPLETE_BONUS, + perfectBonus = if (perfect) Xp.PERFECT_LESSON_BONUS else 0, + fastBonus = if (fast) Xp.FAST_LESSON_BONUS else 0, + firstOfDayBonus = if (firstOfDay) Xp.FIRST_LESSON_OF_DAY else 0, + challengeMultiplier = if (challenge) GamificationConfig.Mistake.CHALLENGE_MODE_XP_MULT else 1.0, + ) + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/RootViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/RootViewModel.kt new file mode 100644 index 0000000..88d3e64 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/RootViewModel.kt @@ -0,0 +1,39 @@ +package com.slovko.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.domain.model.UserSettings +import com.slovko.domain.repository.ContentRepository +import com.slovko.domain.repository.GamificationRepository +import com.slovko.domain.repository.ProgressRepository +import com.slovko.domain.repository.SettingsRepository +import com.slovko.work.NotificationScheduler +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class RootViewModel @Inject constructor( + private val content: ContentRepository, + private val progress: ProgressRepository, + private val gamification: GamificationRepository, + private val scheduler: NotificationScheduler, + settingsRepo: SettingsRepository, +) : ViewModel() { + + val settings: StateFlow = settingsRepo.settings + .stateIn(viewModelScope, SharingStarted.Eagerly, null) + + init { + viewModelScope.launch { + content.ensureSeeded() + progress.ensureProfile() + gamification.rollDailyQuestsIfNeeded() + gamification.refreshLeague() + scheduler.scheduleAll() + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/navigation/Routes.kt b/slovko/app/src/main/java/com/slovko/ui/navigation/Routes.kt new file mode 100644 index 0000000..dd1d0c6 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/navigation/Routes.kt @@ -0,0 +1,24 @@ +package com.slovko.ui.navigation + +object Routes { + const val ONBOARDING = "onboarding" + + // Top-level tabs + const val HOME = "home" + const val PRACTICE = "practice" + const val CHATHUB = "chathub" + const val LEADERBOARD = "leaderboard" + const val PROFILE = "profile" + + // Pushed screens + const val LESSON = "lesson/{lessonId}" + const val CHAT = "chat/{scenarioId}" + const val PHRASEBOOK = "phrasebook" + const val ACHIEVEMENTS = "achievements" + const val SETTINGS = "settings" + + fun lesson(lessonId: String) = "lesson/$lessonId" + fun chat(scenarioId: String) = "chat/$scenarioId" + + val tabs = listOf(HOME, PRACTICE, CHATHUB, LEADERBOARD, PROFILE) +} diff --git a/slovko/app/src/main/java/com/slovko/ui/navigation/SlovkoApp.kt b/slovko/app/src/main/java/com/slovko/ui/navigation/SlovkoApp.kt new file mode 100644 index 0000000..ef71ded --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/navigation/SlovkoApp.kt @@ -0,0 +1,156 @@ +package com.slovko.ui.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.slovko.ui.achievements.AchievementsScreen +import com.slovko.ui.chat.ChatScreen +import com.slovko.ui.chat.PhrasebookScreen +import com.slovko.ui.chathub.ChatHubScreen +import com.slovko.ui.home.HomeScreen +import com.slovko.ui.leaderboard.LeaderboardScreen +import com.slovko.ui.lesson.LessonScreen +import com.slovko.ui.onboarding.OnboardingScreen +import com.slovko.ui.practice.PracticeScreen +import com.slovko.ui.profile.ProfileScreen +import com.slovko.ui.settings.SettingsScreen + +private data class Tab(val route: String, val label: String, val icon: ImageVector) + +private val bottomTabs = listOf( + Tab(Routes.HOME, "Learn", Icons.Filled.Home), + Tab(Routes.PRACTICE, "Practice", Icons.Filled.Refresh), + Tab(Routes.CHATHUB, "Chat", Icons.Filled.Chat), + Tab(Routes.LEADERBOARD, "League", Icons.Filled.EmojiEvents), + Tab(Routes.PROFILE, "Profile", Icons.Filled.Person), +) + +@Composable +fun SlovkoApp( + onboarded: Boolean, + settingsLoaded: Boolean, + deepLinkHost: String?, +) { + if (!settingsLoaded) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + val navController = rememberNavController() + val backStack by navController.currentBackStackEntryAsState() + val currentRoute = backStack?.destination?.route + val showBottomBar = currentRoute in Routes.tabs + + LaunchedEffect(deepLinkHost) { + when (deepLinkHost) { + "practice", "review" -> if (onboarded) navController.navigate(Routes.PRACTICE) + } + } + + Scaffold( + bottomBar = { + if (showBottomBar) { + NavigationBar { + bottomTabs.forEach { tab -> + NavigationBarItem( + selected = currentRoute == tab.route, + onClick = { + navController.navigate(tab.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(tab.icon, contentDescription = tab.label) }, + label = { Text(tab.label) }, + ) + } + } + } + }, + ) { padding -> + NavHost( + navController = navController, + startDestination = if (onboarded) Routes.HOME else Routes.ONBOARDING, + modifier = Modifier.padding(padding), + ) { + composable(Routes.ONBOARDING) { + OnboardingScreen( + onFinish = { + navController.navigate(Routes.HOME) { + popUpTo(Routes.ONBOARDING) { inclusive = true } + } + }, + ) + } + composable(Routes.HOME) { + HomeScreen( + onOpenLesson = { navController.navigate(Routes.lesson(it)) }, + onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + ) + } + composable(Routes.PRACTICE) { + PracticeScreen() + } + composable(Routes.CHATHUB) { + ChatHubScreen( + onOpenChat = { navController.navigate(Routes.chat(it)) }, + onOpenPhrasebook = { navController.navigate(Routes.PHRASEBOOK) }, + ) + } + composable(Routes.LEADERBOARD) { + LeaderboardScreen() + } + composable(Routes.PROFILE) { + ProfileScreen( + onOpenAchievements = { navController.navigate(Routes.ACHIEVEMENTS) }, + onOpenSettings = { navController.navigate(Routes.SETTINGS) }, + ) + } + composable(Routes.LESSON) { entry -> + val lessonId = entry.arguments?.getString("lessonId").orEmpty() + LessonScreen(lessonId = lessonId, onExit = { navController.popBackStack() }) + } + composable(Routes.CHAT) { entry -> + val scenarioId = entry.arguments?.getString("scenarioId").orEmpty() + ChatScreen(scenarioId = scenarioId, onExit = { navController.popBackStack() }) + } + composable(Routes.PHRASEBOOK) { + PhrasebookScreen(onExit = { navController.popBackStack() }) + } + composable(Routes.ACHIEVEMENTS) { + AchievementsScreen(onExit = { navController.popBackStack() }) + } + composable(Routes.SETTINGS) { + SettingsScreen(onExit = { navController.popBackStack() }) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/practice/PracticeScreen.kt b/slovko/app/src/main/java/com/slovko/ui/practice/PracticeScreen.kt new file mode 100644 index 0000000..f60eff5 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/practice/PracticeScreen.kt @@ -0,0 +1,157 @@ +package com.slovko.ui.practice + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.component.MajaMascot +import com.slovko.core.designsystem.component.PrimaryButton +import com.slovko.core.designsystem.component.SecondaryButton +import com.slovko.core.designsystem.component.WordTile +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.CardDirection +import com.slovko.domain.model.Grade + +@Composable +fun PracticeScreen(viewModel: PracticeViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Box(Modifier.fillMaxSize().padding(MaterialTheme.spacing.screenEdge)) { + when (val s = state) { + is PracticeUiState.Loading -> { + Box(Modifier.fillMaxSize(), Alignment.Center) { Text("…") } + } + is PracticeUiState.Empty -> EmptyState() + is PracticeUiState.Reviewing -> ReviewingState(s, viewModel) + is PracticeUiState.Done -> DoneState(s, onAgain = viewModel::load) + } + } +} + +@Composable +private fun EmptyState() { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MajaMascot(pose = "proud") + Spacer(Modifier.height(16.dp)) + Text("All caught up!", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.height(8.dp)) + Text( + "No cards are due right now. Finish a lesson to grow your deck.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ReviewingState(s: PracticeUiState.Reviewing, viewModel: PracticeViewModel) { + val front = if (s.card.direction == CardDirection.SK_TO_EN) s.card.card.sk else s.card.card.en + val back = if (s.card.direction == CardDirection.SK_TO_EN) s.card.card.en else s.card.card.sk + + Column(Modifier.fillMaxSize()) { + LinearProgressIndicator( + progress = { (s.index).toFloat() / s.total }, + modifier = Modifier.fillMaxWidth().height(8.dp), + ) + Spacer(Modifier.height(8.dp)) + Text("${s.index + 1} / ${s.total}", style = MaterialTheme.typography.labelLarge) + + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + front, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + if (s.revealed) { + Spacer(Modifier.height(16.dp)) + Text( + back, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + s.card.card.exampleSk?.let { + Spacer(Modifier.height(12.dp)) + WordTile(text = it, onSpeak = viewModel::speak) + } + } + } + } + } + + if (!s.revealed) { + PrimaryButton( + text = "Show answer", + onClick = viewModel::reveal, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + GradeButton("Again", Grade.AGAIN, viewModel, Modifier.weight(1f)) + GradeButton("Hard", Grade.HARD, viewModel, Modifier.weight(1f)) + GradeButton("Good", Grade.GOOD, viewModel, Modifier.weight(1f)) + GradeButton("Easy", Grade.EASY, viewModel, Modifier.weight(1f)) + } + } + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun GradeButton(label: String, grade: Grade, viewModel: PracticeViewModel, modifier: Modifier) { + SecondaryButton(text = label, onClick = { viewModel.grade(grade) }, modifier = modifier) +} + +@Composable +private fun DoneState(s: PracticeUiState.Done, onAgain: () -> Unit) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MajaMascot(pose = "celebrating") + Spacer(Modifier.height(16.dp)) + Text("Hotovo! 🎉", style = MaterialTheme.typography.displayMedium) + Spacer(Modifier.height(8.dp)) + Text( + "Reviewed ${s.reviewed} cards · +${s.xpEarned} XP", + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(Modifier.height(24.dp)) + PrimaryButton(text = "Review more", onClick = onAgain, modifier = Modifier.widthIn(min = 200.dp)) + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/practice/PracticeViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/practice/PracticeViewModel.kt new file mode 100644 index 0000000..0009419 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/practice/PracticeViewModel.kt @@ -0,0 +1,89 @@ +package com.slovko.ui.practice + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.data.audio.PronunciationPlayer +import com.slovko.domain.GamificationConfig +import com.slovko.domain.model.Grade +import com.slovko.domain.model.ReviewCard +import com.slovko.domain.repository.GamificationRepository +import com.slovko.domain.repository.ProgressRepository +import com.slovko.domain.repository.SrsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface PracticeUiState { + data object Loading : PracticeUiState + data object Empty : PracticeUiState + data class Reviewing( + val card: ReviewCard, + val index: Int, + val total: Int, + val revealed: Boolean, + ) : PracticeUiState + + data class Done(val reviewed: Int, val xpEarned: Int) : PracticeUiState +} + +@HiltViewModel +class PracticeViewModel @Inject constructor( + private val srs: SrsRepository, + private val progress: ProgressRepository, + private val gamification: GamificationRepository, + private val player: PronunciationPlayer, +) : ViewModel() { + + private val _state = MutableStateFlow(PracticeUiState.Loading) + val state: StateFlow = _state.asStateFlow() + + private var queue: List = emptyList() + private var index = 0 + private var xp = 0 + + init { load() } + + fun load() { + viewModelScope.launch { + queue = srs.dueCards(limit = 30) + index = 0 + xp = 0 + _state.value = if (queue.isEmpty()) PracticeUiState.Empty else current(revealed = false) + } + } + + private fun current(revealed: Boolean) = PracticeUiState.Reviewing( + card = queue[index], index = index, total = queue.size, revealed = revealed, + ) + + fun reveal() { + val s = _state.value + if (s is PracticeUiState.Reviewing) _state.value = s.copy(revealed = true) + } + + fun speak() { + (_state.value as? PracticeUiState.Reviewing)?.let { player.speak(it.card.card.sk) } + } + + fun grade(grade: Grade) { + val card = queue[index] + viewModelScope.launch { + srs.grade(card.card.id, card.direction, grade) + if (grade != Grade.AGAIN) { + xp += GamificationConfig.Xp.SRS_REVIEW + progress.addXp(GamificationConfig.Xp.SRS_REVIEW) + } + index++ + if (index >= queue.size) { + gamification.progressQuests(xp = xp, lessons = 0, perfect = false) + gamification.evaluateAchievements() + _state.value = PracticeUiState.Done(reviewed = queue.size, xpEarned = xp) + } else { + _state.value = current(revealed = false) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/widget/SlovkoWidgetReceiver.kt b/slovko/app/src/main/java/com/slovko/widget/SlovkoWidgetReceiver.kt new file mode 100644 index 0000000..7db1e5f --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/widget/SlovkoWidgetReceiver.kt @@ -0,0 +1,75 @@ +package com.slovko.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.RemoteViews +import com.slovko.R +import com.slovko.domain.repository.ContentRepository +import com.slovko.domain.repository.ProgressRepository +import com.slovko.domain.repository.SrsRepository +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class SlovkoWidgetReceiver : AppWidgetProvider() { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface WidgetEntryPoint { + fun progress(): ProgressRepository + fun content(): ContentRepository + fun srs(): SrsRepository + } + + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { + val ep = EntryPointAccessors.fromApplication( + context.applicationContext, WidgetEntryPoint::class.java, + ) + val pending = goAsyncSafe() + CoroutineScope(Dispatchers.Default).launch { + val streak = runCatching { ep.progress().observeUserStats().first().currentStreak }.getOrDefault(0) + val due = runCatching { ep.srs().dueCount() }.getOrDefault(0) + val word = runCatching { ep.content().wordOfTheDay()?.sk }.getOrNull() ?: "ahoj" + + for (id in ids) { + val views = RemoteViews(context.packageName, R.layout.widget_slovko).apply { + setTextViewText(R.id.widget_streak, "🔥 $streak") + setTextViewText(R.id.widget_word, word) + setTextViewText( + R.id.widget_due, + if (due > 0) context.getString(R.string.widget_cards_due, due) + else context.getString(R.string.widget_practice_now), + ) + setOnClickPendingIntent(R.id.widget_root, openIntent(context)) + } + manager.updateAppWidget(id, views) + } + pending?.invoke() + } + } + + private fun openIntent(context: Context): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("slovko://practice")).apply { + setPackage(context.packageName) + } + return PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + // goAsync() returns a PendingResult; wrap to a simple finisher. + private fun goAsyncSafe(): (() -> Unit)? { + val result = goAsync() + return { result.finish() } + } +} diff --git a/slovko/app/src/main/java/com/slovko/work/BootReceiver.kt b/slovko/app/src/main/java/com/slovko/work/BootReceiver.kt new file mode 100644 index 0000000..b79b9d0 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/work/BootReceiver.kt @@ -0,0 +1,31 @@ +package com.slovko.work + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Reschedules all reminder work after reboot / timezone change. See DESIGN.md §14. */ +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + + @Inject lateinit var scheduler: NotificationScheduler + + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action ?: return + if (action == Intent.ACTION_BOOT_COMPLETED || action == Intent.ACTION_TIMEZONE_CHANGED) { + val pending = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + scheduler.rescheduleAll() + } finally { + pending.finish() + } + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/work/NotificationScheduler.kt b/slovko/app/src/main/java/com/slovko/work/NotificationScheduler.kt new file mode 100644 index 0000000..c4312c4 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/work/NotificationScheduler.kt @@ -0,0 +1,76 @@ +package com.slovko.work + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.slovko.domain.repository.SettingsRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** Central (re)enqueue of all reminder work. Idempotent unique names. See DESIGN.md §14. */ +@Singleton +class NotificationScheduler @Inject constructor( + @ApplicationContext private val context: Context, + private val settings: SettingsRepository, +) { + private val wm get() = WorkManager.getInstance(context) + + suspend fun scheduleAll() { + val s = settings.current() + + // Daily practice reminder at the chosen time. + if (s.dailyReminderOn) { + val delay = delayUntil(s.reminderHour, s.reminderMinute) + val req = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .build() + wm.enqueueUniquePeriodicWork(DAILY, ExistingPeriodicWorkPolicy.UPDATE, req) + } else { + wm.cancelUniqueWork(DAILY) + } + + // Review-due nudge, a few times a day. + if (s.reviewReminderOn) { + val req = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setInitialDelay(2, TimeUnit.HOURS) + .build() + wm.enqueueUniquePeriodicWork(REVIEW, ExistingPeriodicWorkPolicy.KEEP, req) + } else { + wm.cancelUniqueWork(REVIEW) + } + + // Evening streak-at-risk check. + if (s.streakReminderOn) { + val delay = delayUntil(20, 30) + val req = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .build() + wm.enqueueUniquePeriodicWork(STREAK, ExistingPeriodicWorkPolicy.UPDATE, req) + } else { + wm.cancelUniqueWork(STREAK) + } + } + + suspend fun rescheduleAll() = scheduleAll() + + private fun delayUntil(hour: Int, minute: Int): Long { + val zone = ZoneId.systemDefault() + val now = LocalDateTime.now(zone) + var target = now.toLocalDate().atTime(LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))) + if (!target.isAfter(now)) target = target.plusDays(1) + return ChronoUnit.MILLIS.between(now, target).coerceAtLeast(60_000L) + } + + companion object { + const val DAILY = "slovko_daily_reminder" + const val REVIEW = "slovko_review_due" + const val STREAK = "slovko_streak_risk" + } +} diff --git a/slovko/app/src/main/java/com/slovko/work/Workers.kt b/slovko/app/src/main/java/com/slovko/work/Workers.kt new file mode 100644 index 0000000..c9ac9bc --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/work/Workers.kt @@ -0,0 +1,89 @@ +package com.slovko.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.slovko.core.notification.NotificationChannels +import com.slovko.core.notification.NotificationBuilders +import com.slovko.core.notification.NotificationCopyProvider +import com.slovko.domain.repository.ProgressRepository +import com.slovko.domain.repository.SettingsRepository +import com.slovko.domain.repository.SrsRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.first + +/** Daily practice reminder — skips if already practiced today. */ +@HiltWorker +class DailyReminderWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val progress: ProgressRepository, + private val settings: SettingsRepository, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val s = settings.current() + if (s.dailyReminderOn && !progress.hasPracticedToday()) { + NotificationBuilders.post( + applicationContext, + NotificationChannels.DAILY, + NID_DAILY, + NotificationCopyProvider.forChannel(NotificationChannels.DAILY, null), + deepLinkHost = "practice", + ) + } + return Result.success() + } +} + +/** SRS review-due nudge. */ +@HiltWorker +class ReviewDueWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val srs: SrsRepository, + private val settings: SettingsRepository, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val s = settings.current() + if (s.reviewReminderOn && srs.dueCount() >= 10) { + NotificationBuilders.post( + applicationContext, + NotificationChannels.REVIEWS, + NID_REVIEW, + NotificationCopyProvider.forChannel(NotificationChannels.REVIEWS, null), + deepLinkHost = "review", + ) + } + return Result.success() + } +} + +/** Evening streak-at-risk reminder. */ +@HiltWorker +class StreakRiskWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val progress: ProgressRepository, + private val settings: SettingsRepository, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + val s = settings.current() + val streak = progress.observeUserStats().first().currentStreak + if (s.streakReminderOn && streak > 0 && !progress.hasPracticedToday()) { + NotificationBuilders.post( + applicationContext, + NotificationChannels.STREAK, + NID_STREAK, + NotificationCopyProvider.forChannel(NotificationChannels.STREAK, null), + deepLinkHost = "practice", + ) + } + return Result.success() + } +} + +private const val NID_DAILY = 1001 +private const val NID_REVIEW = 1002 +private const val NID_STREAK = 1003 diff --git a/slovko/app/src/main/res/drawable/ic_launcher_background.xml b/slovko/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d222714 --- /dev/null +++ b/slovko/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/slovko/app/src/main/res/drawable/ic_launcher_foreground.xml b/slovko/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..61b608d --- /dev/null +++ b/slovko/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/slovko/app/src/main/res/drawable/ic_stat_slovko.xml b/slovko/app/src/main/res/drawable/ic_stat_slovko.xml new file mode 100644 index 0000000..76e8f45 --- /dev/null +++ b/slovko/app/src/main/res/drawable/ic_stat_slovko.xml @@ -0,0 +1,13 @@ + + + + diff --git a/slovko/app/src/main/res/drawable/widget_background.xml b/slovko/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 0000000..1965f99 --- /dev/null +++ b/slovko/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/slovko/app/src/main/res/layout/widget_slovko.xml b/slovko/app/src/main/res/layout/widget_slovko.xml new file mode 100644 index 0000000..5250fb7 --- /dev/null +++ b/slovko/app/src/main/res/layout/widget_slovko.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + diff --git a/slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/slovko/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/slovko/app/src/main/res/values/colors.xml b/slovko/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f62a4f4 --- /dev/null +++ b/slovko/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #C0392B + #F2A900 + #2E6E4E + #FFFBF5 + #231A14 + #C0392B + #F7EFE3 + #271E18 + diff --git a/slovko/app/src/main/res/values/strings.xml b/slovko/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..46970ed --- /dev/null +++ b/slovko/app/src/main/res/values/strings.xml @@ -0,0 +1,29 @@ + + Slovko + Po našom. + + + Learn + Practice + Chat + League + Profile + + + Daily practice + Your gentle daily Slovak nudge. + Streak reminders + A heads-up when your streak is at risk. + Reviews due + When cards are ready for review. + Come back + Friendly check-ins if you drift away. + Achievements + Celebrate what you unlock. + Slovko + + + Practice now + %1$d cards due + Word of the day + diff --git a/slovko/app/src/main/res/values/themes.xml b/slovko/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..21714c7 --- /dev/null +++ b/slovko/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + diff --git a/slovko/app/src/main/res/xml/slovko_widget_info.xml b/slovko/app/src/main/res/xml/slovko_widget_info.xml new file mode 100644 index 0000000..6f7f147 --- /dev/null +++ b/slovko/app/src/main/res/xml/slovko_widget_info.xml @@ -0,0 +1,11 @@ + + diff --git a/slovko/build.gradle.kts b/slovko/build.gradle.kts new file mode 100644 index 0000000..008483a --- /dev/null +++ b/slovko/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.hilt) apply false +} diff --git a/slovko/build/reports/problems/problems-report.html b/slovko/build/reports/problems/problems-report.html new file mode 100644 index 0000000..2b2a973 --- /dev/null +++ b/slovko/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/slovko/gradle.properties b/slovko/gradle.properties new file mode 100644 index 0000000..75a62ea --- /dev/null +++ b/slovko/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.configuration-cache=false +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +ksp.incremental=true diff --git a/slovko/gradle/libs.versions.toml b/slovko/gradle/libs.versions.toml new file mode 100644 index 0000000..8d27058 --- /dev/null +++ b/slovko/gradle/libs.versions.toml @@ -0,0 +1,66 @@ +[versions] +agp = "8.7.3" +kotlin = "2.0.21" +ksp = "2.0.21-1.0.28" +coreKtx = "1.13.1" +lifecycle = "2.8.7" +activityCompose = "1.9.3" +composeBom = "2024.12.01" +navigation = "2.8.5" +room = "2.6.1" +datastore = "1.1.1" +work = "2.10.0" +hilt = "2.52" +hiltExt = "1.2.0" +coroutines = "1.9.0" +serialization = "1.7.3" +junit = "4.13.2" +turbine = "1.2.0" +coroutinesTest = "1.9.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } + +androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } + +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } + +hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltExt" } +androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" } +androidx-hilt-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } + +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } + +junit = { group = "junit", name = "junit", version.ref = "junit" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/slovko/gradle/wrapper/gradle-wrapper.jar b/slovko/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/slovko/gradlew.bat b/slovko/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/slovko/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/slovko/settings.gradle.kts b/slovko/settings.gradle.kts new file mode 100644 index 0000000..125a129 --- /dev/null +++ b/slovko/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Slovko" +include(":app") From f9d101967e57c2c90be705ec8aba3f9959e81ba7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 18:47:33 +0000 Subject: [PATCH 2/6] Slovko: CI workflow, domain unit tests, README, content manifest Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KcutcC6MjorhCn8EAfHDcP --- .github/workflows/android-ci.yml | 49 ++ slovko/README.md | 42 ++ .../src/main/assets/curriculum/manifest.json | 20 + .../java/com/slovko/ui/home/HomeScreen.kt | 204 +++++++++ .../java/com/slovko/ui/home/HomeViewModel.kt | 114 +++++ .../slovko/ui/onboarding/OnboardingScreen.kt | 431 ++++++++++++++++++ .../ui/onboarding/OnboardingViewModel.kt | 20 + .../slovko/domain/league/LeagueEngineTest.kt | 40 ++ .../slovko/domain/srs/FsrsSchedulerTest.kt | 55 +++ .../domain/usecase/GradeAnswerUseCaseTest.kt | 57 +++ .../slovko/domain/usecase/XpCalculatorTest.kt | 44 ++ 11 files changed, 1076 insertions(+) create mode 100644 .github/workflows/android-ci.yml create mode 100644 slovko/README.md create mode 100644 slovko/app/src/main/assets/curriculum/manifest.json create mode 100644 slovko/app/src/main/java/com/slovko/ui/home/HomeScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/home/HomeViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingViewModel.kt create mode 100644 slovko/app/src/test/java/com/slovko/domain/league/LeagueEngineTest.kt create mode 100644 slovko/app/src/test/java/com/slovko/domain/srs/FsrsSchedulerTest.kt create mode 100644 slovko/app/src/test/java/com/slovko/domain/usecase/GradeAnswerUseCaseTest.kt create mode 100644 slovko/app/src/test/java/com/slovko/domain/usecase/XpCalculatorTest.kt diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml new file mode 100644 index 0000000..02017f5 --- /dev/null +++ b/.github/workflows/android-ci.yml @@ -0,0 +1,49 @@ +name: Slovko Android CI + +on: + push: + branches: ["**"] + paths: + - "slovko/**" + - ".github/workflows/android-ci.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: slovko + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + build-root-directory: slovko + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Build debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: slovko-debug-apk + path: slovko/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error diff --git a/slovko/README.md b/slovko/README.md new file mode 100644 index 0000000..1de412e --- /dev/null +++ b/slovko/README.md @@ -0,0 +1,42 @@ +# Slovko 🦊 — *Po našom.* + +A beautiful, gamified, **offline-first Android app for learning modern spoken Slovak** — built to get you chatting with your Slovak friends. + +Meet **Líška Maja**, your fox guide, and learn the Slovak people *actually* speak: texting slang, café orders, banter — not textbook formal register. + +## Features + +- **Skill-tree curriculum** — A0 → A2, 13 units from *Zvuky* (sounds) to *Píšeme si* (real WhatsApp chats), with explicit teaching of the 6 grammatical cases, verb aspect, and *vykať/tykať*. +- **FSRS spaced repetition** — a pure-Kotlin FSRS-4.5 scheduler decides exactly when to review each word, on-device. +- **The Daily Brew** — a ~7-minute daily ritual: warm-up → lesson → reviews → a chat moment. +- **Chat track** — branching scenario dialogues + a searchable texting phrasebook + an optional, pluggable AI conversation partner. +- **Gamification, done ethically** — XP, levels, streaks (with freezes), daily quests, 15 achievements, and a backend-free simulated **league**. No lockouts, no shame, nothing pay-to-win. +- **Smart notifications** — daily reminder at your chosen time, review-due nudges, evening streak-rescue — all local, reboot-safe. +- **Home-screen widget** — streak + word of the day + one-tap practice. +- **Beautiful & accessible** — Material 3, light/dark, dynamic color, Čičmany folk-art accents, reduced-motion support, full TTS. + +## Architecture + +Single-module, clean layering — `ui → domain → data`. Jetpack Compose, Hilt, Room, DataStore, WorkManager, Coroutines/Flow. The `domain` layer is pure Kotlin and JVM-unit-tested (FSRS, grading, XP, league). + +See [`DESIGN.md`](DESIGN.md) for the full product + technical design. + +## Building the APK + +Requires the Android SDK + JDK 17/21. + +```bash +cd slovko +./gradlew assembleDebug +# → app/build/outputs/apk/debug/app-debug.apk +``` + +**No local Android SDK?** Push to GitHub — the workflow at `.github/workflows/android-ci.yml` +runs the unit tests and builds the debug APK on every push, uploading it as the +**`slovko-debug-apk`** artifact (downloadable from the Actions run). + +## Tests + +```bash +./gradlew testDebugUnitTest +``` diff --git a/slovko/app/src/main/assets/curriculum/manifest.json b/slovko/app/src/main/assets/curriculum/manifest.json new file mode 100644 index 0000000..cb5d0b7 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/manifest.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "skillFiles": [ + "unit0-zvuky.json", + "unit1-ahoj.json", + "unit2-ja-a-ty.json", + "unit3-rodina.json", + "unit4-jedlo-kava.json", + "unit5-v-meste.json", + "unit6-cas-plany.json", + "unit7-kazdy-den.json", + "unit8-nakupovanie.json", + "unit9-pocasie-pocity.json", + "unit10-praca-skola.json", + "unit11-cestovanie.json", + "unit12-piseme-si.json" + ], + "scenarioFile": "scenarios.json", + "phrasebookFile": "phrasebook.json" +} diff --git a/slovko/app/src/main/java/com/slovko/ui/home/HomeScreen.kt b/slovko/app/src/main/java/com/slovko/ui/home/HomeScreen.kt new file mode 100644 index 0000000..50808c5 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/home/HomeScreen.kt @@ -0,0 +1,204 @@ +package com.slovko.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.IconKeys +import com.slovko.core.designsystem.component.CicmanyDivider +import com.slovko.core.designsystem.component.NodeState +import com.slovko.core.designsystem.component.SkillNode +import com.slovko.core.designsystem.component.StreakFlame +import com.slovko.core.designsystem.component.XpBar +import com.slovko.core.designsystem.spacing + +@Composable +fun HomeScreen( + onOpenLesson: (String) -> Unit, + onOpenSettings: () -> Unit, + viewModel: HomeViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + when (val s = state) { + is HomeUiState.Loading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is HomeUiState.Data -> HomeContent(s, onOpenLesson, onOpenSettings) + } +} + +@Composable +private fun HomeContent( + data: HomeUiState.Data, + onOpenLesson: (String) -> Unit, + onOpenSettings: () -> Unit, +) { + val spacing = MaterialTheme.spacing + Column(Modifier.fillMaxSize()) { + // Sticky top bar: streak, gems, settings. + Surface( + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(horizontal = spacing.screenEdge, vertical = spacing.sm)) { + Row(verticalAlignment = Alignment.CenterVertically) { + StreakFlame(count = data.stats.currentStreak) + Spacer(Modifier.width(spacing.md)) + Text( + "💎 ${data.stats.gems}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.weight(1f)) + IconButton(onClick = onOpenSettings) { + Icon( + Icons.Filled.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(spacing.sm)) + XpBar( + progress = data.goal.progress, + label = "${data.goal.earnedXp}/${data.goal.goalXp} XP", + modifier = Modifier.fillMaxWidth(), + ) + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = spacing.screenEdge, + vertical = spacing.lg, + ), + verticalArrangement = Arrangement.spacedBy(spacing.lg), + ) { + data.units.forEach { unit -> + item(key = "unit-${unit.unitId}") { + UnitHeader(name = unit.name, cefr = unit.cefr) + } + item(key = "divider-${unit.unitId}") { + CicmanyDivider() + } + itemsIndexed(unit.skills) { index, skill -> + SkillRow( + skill = skill, + leftAligned = index % 2 == 0, + onClick = { + if (skill.nodeState != NodeState.LOCKED) { + skill.nextLessonId?.let(onOpenLesson) + } + }, + ) + } + } + } + } +} + +@Composable +private fun UnitHeader(name: String, cefr: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f, fill = false), + ) + Spacer(Modifier.width(MaterialTheme.spacing.xs)) + CefrChip(cefr) + } +} + +@Composable +private fun CefrChip(cefr: String) { + Surface( + shape = androidx.compose.foundation.shape.RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + cefr, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } +} + +@Composable +private fun SkillRow( + skill: SkillNodeUi, + leftAligned: Boolean, + onClick: () -> Unit, +) { + val alignment = if (leftAligned) Alignment.Start else Alignment.End + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = alignment, + ) { + // Indent the node toward one side to create a winding path. + Box( + Modifier + .fillMaxWidth() + .padding( + start = if (leftAligned) 24.dp else 0.dp, + end = if (leftAligned) 0.dp else 24.dp, + ), + contentAlignment = if (leftAligned) Alignment.CenterStart else Alignment.CenterEnd, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + SkillNode( + state = skill.nodeState, + glyph = IconKeys[skill.iconKey], + progress = skill.progress, + onClick = onClick, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + skill.title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = if (skill.nodeState == NodeState.LOCKED) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.width(120.dp), + ) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/home/HomeViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..fa3f02d --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/home/HomeViewModel.kt @@ -0,0 +1,114 @@ +package com.slovko.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.core.designsystem.component.NodeState +import com.slovko.domain.model.CurriculumUnit +import com.slovko.domain.model.DailyGoal +import com.slovko.domain.model.UserStats +import com.slovko.domain.repository.ContentRepository +import com.slovko.domain.repository.ProgressRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +/** A single skill rendered as a node on the winding path. */ +data class SkillNodeUi( + val skillId: String, + val title: String, + val iconKey: String, + val nodeState: NodeState, + val progress: Float, + val nextLessonId: String?, +) + +/** A unit header followed by its skill nodes. */ +data class UnitSectionUi( + val unitId: String, + val name: String, + val cefr: String, + val skills: List, +) + +sealed interface HomeUiState { + data object Loading : HomeUiState + data class Data( + val stats: UserStats, + val goal: DailyGoal, + val units: List, + ) : HomeUiState +} + +@HiltViewModel +class HomeViewModel @Inject constructor( + content: ContentRepository, + progress: ProgressRepository, +) : ViewModel() { + + val state: StateFlow = combine( + content.observeUnits(), + progress.observeCompletedLessons(), + progress.observeUserStats(), + progress.observeDailyGoal(), + ) { units, completed, stats, goal -> + HomeUiState.Data( + stats = stats, + goal = goal, + units = buildSections(units, completed), + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + HomeUiState.Loading, + ) + + private fun buildSections( + units: List, + completed: Set, + ): List { + // Walk all skills in unit/order sequence. The first non-complete skill + // overall is AVAILABLE; everything after it is LOCKED. + var availableAssigned = false + + val orderedUnits = units.sortedBy { it.order } + return orderedUnits.map { unit -> + val orderedSkills = unit.skills.sortedBy { it.orderIndex } + val nodes = orderedSkills.map { skill -> + val lessons = skill.lessons.sortedBy { it.orderIndex } + val total = lessons.size + val completedCount = lessons.count { it.id in completed } + val allComplete = total > 0 && completedCount == total + + val nextLessonId = lessons.firstOrNull { it.id !in completed }?.id + ?: lessons.firstOrNull()?.id + + val nodeState = when { + allComplete -> NodeState.COMPLETE + !availableAssigned -> { + availableAssigned = true + NodeState.AVAILABLE + } + else -> NodeState.LOCKED + } + + SkillNodeUi( + skillId = skill.id, + title = skill.title, + iconKey = skill.iconKey, + nodeState = nodeState, + progress = if (total <= 0) 0f else completedCount.toFloat() / total, + nextLessonId = nextLessonId, + ) + } + UnitSectionUi( + unitId = unit.id, + name = unit.name, + cefr = unit.cefr, + skills = nodes, + ) + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt b/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..979bc99 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt @@ -0,0 +1,431 @@ +package com.slovko.ui.onboarding + +import android.Manifest +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.slovko.core.designsystem.component.CicmanyDivider +import com.slovko.core.designsystem.component.MajaMascot +import com.slovko.core.designsystem.component.OptionChip +import com.slovko.core.designsystem.component.OptionState +import com.slovko.core.designsystem.component.PrimaryButton +import com.slovko.core.designsystem.component.SecondaryButton +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.GamificationConfig +import kotlinx.coroutines.launch + +private const val PAGE_COUNT = 5 + +private val GOALS = listOf( + "Chat with friends", + "Travel", + "Culture", + "Keep sharp", +) + +private data class LevelOption(val title: String, val blurb: String, val emoji: String) + +private val LEVELS = listOf( + LevelOption("Brand new", "Začíname od nuly — and that's perfect.", "🌱"), + LevelOption("I know a little", "A few words and phrases already.", "🌤️"), + LevelOption("I can get by", "I can hold a simple conversation.", "🏔️"), +) + +@Composable +fun OnboardingScreen( + onFinish: () -> Unit, + viewModel: OnboardingViewModel = hiltViewModel(), +) { + val pagerState = rememberPagerState(pageCount = { PAGE_COUNT }) + val scope = rememberCoroutineScope() + + var goalIndex by remember { mutableIntStateOf(0) } + var levelIndex by remember { mutableIntStateOf(0) } + var goalXp by remember { mutableIntStateOf(GamificationConfig.DEFAULT_DAILY_GOAL) } + + val notifLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { _ -> + viewModel.finish(goalXp) + onFinish() + } + + fun finishNow() { + viewModel.finish(goalXp) + onFinish() + } + + fun requestReminders() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + finishNow() + } + } + + Column(Modifier.fillMaxSize().padding(MaterialTheme.spacing.screenEdge)) { + StepDots(current = pagerState.currentPage, total = PAGE_COUNT) + Spacer(Modifier.height(MaterialTheme.spacing.md)) + + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) { page -> + when (page) { + 0 -> WelcomePage() + 1 -> WhyPage(selected = goalIndex, onSelect = { goalIndex = it }) + 2 -> LevelPage(selected = levelIndex, onSelect = { levelIndex = it }) + 3 -> GoalPage(selectedXp = goalXp, onSelect = { goalXp = it }) + else -> RemindersPage() + } + } + + Spacer(Modifier.height(MaterialTheme.spacing.md)) + BottomBar( + page = pagerState.currentPage, + isLast = pagerState.currentPage == PAGE_COUNT - 1, + onBack = { + scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } + }, + onContinue = { + scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } + }, + onTurnOnReminders = ::requestReminders, + onMaybeLater = ::finishNow, + ) + } +} + +@Composable +private fun StepDots(current: Int, total: Int) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.xs, + Alignment.CenterHorizontally, + ), + ) { + repeat(total) { i -> + val active = i <= current + val dotWidth: Dp = if (i == current) 28.dp else 14.dp + Box( + Modifier + .height(6.dp) + .width(dotWidth) + .clip(RoundedCornerShape(3.dp)) + .background( + if (active) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + ), + ) + } + } +} + +@Composable +private fun PageColumn(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) +} + +@Composable +private fun WelcomePage() { + PageColumn { + Spacer(Modifier.height(MaterialTheme.spacing.xxl)) + MajaMascot(pose = "waving", size = 120) + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + Text( + "Slovko", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + "Po našom.", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + CicmanyDivider() + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + Text( + "Learn Slovak a little every day — the warm, friendly way.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.md), + ) + } +} + +@Composable +private fun PageHeader(title: String, subtitle: String? = null) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + if (subtitle != null) { + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + subtitle, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun WhyPage(selected: Int, onSelect: (Int) -> Unit) { + PageColumn { + Spacer(Modifier.height(MaterialTheme.spacing.xl)) + PageHeader("Why learn Slovak?", "Pick what matters most to you.") + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + GOALS.forEachIndexed { i, goal -> + OptionChip( + text = goal, + state = if (i == selected) OptionState.SELECTED else OptionState.DEFAULT, + onClick = { onSelect(i) }, + modifier = Modifier.padding(vertical = MaterialTheme.spacing.xxs), + ) + } + } +} + +@Composable +private fun LevelPage(selected: Int, onSelect: (Int) -> Unit) { + PageColumn { + Spacer(Modifier.height(MaterialTheme.spacing.xl)) + PageHeader("How much Slovak do you know?") + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + LEVELS.forEachIndexed { i, level -> + LevelCard( + option = level, + selected = i == selected, + onClick = { onSelect(i) }, + ) + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + } + } +} + +@Composable +private fun LevelCard(option: LevelOption, selected: Boolean, onClick: () -> Unit) { + val container = + if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainer + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = container), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(option.emoji, style = MaterialTheme.typography.displaySmall) + Spacer(Modifier.width(MaterialTheme.spacing.md)) + Column { + Text( + option.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + option.blurb, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun GoalPage(selectedXp: Int, onSelect: (Int) -> Unit) { + val tiers = GamificationConfig.DAILY_GOAL_TIERS + val selectedTier = tiers.firstOrNull { it.second == selectedXp } + val pose = when { + selectedXp >= 250 -> "celebrating" + selectedXp >= 120 -> "proud" + else -> "waving" + } + PageColumn { + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + MajaMascot(pose = pose, size = 96) + Spacer(Modifier.height(MaterialTheme.spacing.md)) + PageHeader("Daily goal", "How much do you want to do each day?") + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(MaterialTheme.spacing.xs)) { + tiers.forEach { (label, xp) -> + GoalTierRow( + label = label, + xp = xp, + selected = xp == selectedXp, + onClick = { onSelect(xp) }, + ) + } + } + } + Spacer(Modifier.height(MaterialTheme.spacing.md)) + Text( + selectedTier?.let { "${it.first} · ${it.second} XP a day" } ?: "${selectedXp} XP a day", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.slovkoColors.gold, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun GoalTierRow(label: String, xp: Int, selected: Boolean, onClick: () -> Unit) { + val container = + if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surface + val content = + if (selected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + Surface( + onClick = onClick, + shape = RoundedCornerShape(12.dp), + color = container, + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.xxs), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.md, vertical = MaterialTheme.spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = content, + ) + Text( + "$xp XP", + style = MaterialTheme.typography.bodyLarge, + color = content, + ) + } + } +} + +@Composable +private fun RemindersPage() { + PageColumn { + Spacer(Modifier.height(MaterialTheme.spacing.xl)) + MajaMascot(pose = "thinking", size = 110) + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + PageHeader("Stay on track") + Spacer(Modifier.height(MaterialTheme.spacing.md)) + Text( + "A gentle nudge once a day keeps your streak alive. No spam, no guilt — just a friendly reminder from Maja when it's time to practise.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = MaterialTheme.spacing.sm), + ) + } +} + +@Composable +private fun BottomBar( + page: Int, + isLast: Boolean, + onBack: () -> Unit, + onContinue: () -> Unit, + onTurnOnReminders: () -> Unit, + onMaybeLater: () -> Unit, +) { + if (isLast) { + Column( + Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + PrimaryButton( + text = "Turn on reminders", + onClick = onTurnOnReminders, + modifier = Modifier.fillMaxWidth(), + ) + SecondaryButton( + text = "Maybe later", + onClick = onMaybeLater, + modifier = Modifier.fillMaxWidth(), + ) + } + } else { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + if (page > 0) { + SecondaryButton( + text = "Back", + onClick = onBack, + modifier = Modifier.weight(1f), + ) + } + PrimaryButton( + text = "Continue", + onClick = onContinue, + modifier = Modifier.weight(if (page > 0) 2f else 1f), + ) + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..cdda4eb --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,20 @@ +package com.slovko.ui.onboarding + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.domain.repository.SettingsRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnboardingViewModel @Inject constructor( + private val settings: SettingsRepository, +) : ViewModel() { + + fun finish(goalXp: Int) { + viewModelScope.launch { + settings.update { it.copy(onboarded = true, dailyGoalXp = goalXp) } + } + } +} diff --git a/slovko/app/src/test/java/com/slovko/domain/league/LeagueEngineTest.kt b/slovko/app/src/test/java/com/slovko/domain/league/LeagueEngineTest.kt new file mode 100644 index 0000000..4982d3c --- /dev/null +++ b/slovko/app/src/test/java/com/slovko/domain/league/LeagueEngineTest.kt @@ -0,0 +1,40 @@ +package com.slovko.domain.league + +import com.slovko.domain.GamificationConfig +import com.slovko.domain.model.LeagueTier +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LeagueEngineTest { + + private val engine = LeagueEngine() + + @Test + fun `bot generation is deterministic for a given week`() { + val a = engine.generateBots("2026-W26", 60) + val b = engine.generateBots("2026-W26", 60) + assertEquals(a.map { it.name to it.dailyMean }, b.map { it.name to it.dailyMean }) + } + + @Test + fun `cohort has the configured size including the user`() { + val league = engine.standings( + weekId = "2026-W26", tier = LeagueTier.BRONZE, userXp = 120, + userName = "Ty", userAvatar = "fox", userTrailingDailyAvg = 60, + daysElapsed = 3.0, daysRemaining = 4, + ) + assertEquals(GamificationConfig.League.COHORT_SIZE, league.standings.size) + assertTrue(league.standings.any { it.isUser }) + } + + @Test + fun `standings are sorted by xp descending with ranks assigned`() { + val league = engine.standings( + "2026-W26", LeagueTier.SILVER, 500, "Ty", "fox", 80, 5.0, 2, + ) + val xps = league.standings.map { it.xp } + assertEquals(xps.sortedDescending(), xps) + assertEquals(1, league.standings.first().rank) + } +} diff --git a/slovko/app/src/test/java/com/slovko/domain/srs/FsrsSchedulerTest.kt b/slovko/app/src/test/java/com/slovko/domain/srs/FsrsSchedulerTest.kt new file mode 100644 index 0000000..678f827 --- /dev/null +++ b/slovko/app/src/test/java/com/slovko/domain/srs/FsrsSchedulerTest.kt @@ -0,0 +1,55 @@ +package com.slovko.domain.srs + +import com.slovko.domain.model.CardState +import com.slovko.domain.model.Grade +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FsrsSchedulerTest { + + private val scheduler = FsrsScheduler() + private val now = 1_700_000_000_000L + + private fun newState() = SchedulingState(CardState.NEW, 0.0, 0.0, now, 0, 0, null) + + @Test + fun `good on a new card graduates to review with a future due date`() { + val next = scheduler.review(newState(), Grade.GOOD, now) + assertEquals(CardState.REVIEW, next.state) + assertTrue("due should be in the future", next.dueMillis > now) + assertTrue("stability positive", next.stability > 0.0) + assertEquals(1, next.reps) + } + + @Test + fun `again on a new card stays in learning with a short step`() { + val next = scheduler.review(newState(), Grade.AGAIN, now) + assertEquals(CardState.LEARNING, next.state) + assertTrue("short learning step (< 1 day)", next.dueMillis - now < 86_400_000L) + } + + @Test + fun `easy schedules further out than good`() { + val good = scheduler.review(newState(), Grade.GOOD, now) + val easy = scheduler.review(newState(), Grade.EASY, now) + assertTrue("easy interval >= good interval", easy.dueMillis >= good.dueMillis) + } + + @Test + fun `lapsing a review card increments lapses and relearns`() { + val review = scheduler.review(newState(), Grade.GOOD, now) + val later = review.dueMillis + 1000 + val lapsed = scheduler.review(review, Grade.AGAIN, later) + assertEquals(CardState.RELEARNING, lapsed.state) + assertEquals(1, lapsed.lapses) + } + + @Test + fun `retrievability decays toward zero over time`() { + val r0 = scheduler.retrievability(0.0, 10.0) + val r100 = scheduler.retrievability(100.0, 10.0) + assertTrue(r0 > r100) + assertTrue(r0 <= 1.0) + } +} diff --git a/slovko/app/src/test/java/com/slovko/domain/usecase/GradeAnswerUseCaseTest.kt b/slovko/app/src/test/java/com/slovko/domain/usecase/GradeAnswerUseCaseTest.kt new file mode 100644 index 0000000..dac5510 --- /dev/null +++ b/slovko/app/src/test/java/com/slovko/domain/usecase/GradeAnswerUseCaseTest.kt @@ -0,0 +1,57 @@ +package com.slovko.domain.usecase + +import com.slovko.domain.model.Exercise +import com.slovko.domain.model.ExerciseType +import com.slovko.domain.model.Grade +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GradeAnswerUseCaseTest { + + private val useCase = GradeAnswerUseCase() + + private fun ex( + type: ExerciseType, + answer: String, + acceptable: List = emptyList(), + choices: List = emptyList(), + ) = Exercise( + id = "e", lessonId = "l", orderIndex = 0, type = type, + promptSk = null, promptEn = null, answer = answer, + acceptable = acceptable, choices = choices, + ) + + @Test + fun `mcq exact match is correct and graded good`() { + val result = useCase(ex(ExerciseType.MCQ, "ahoj"), "ahoj") + assertTrue(result.correct) + assertEquals(Grade.GOOD, result.grade) + } + + @Test + fun `mcq wrong answer is graded again`() { + val result = useCase(ex(ExerciseType.MCQ, "ahoj"), "dovidenia") + assertFalse(result.correct) + assertEquals(Grade.AGAIN, result.grade) + } + + @Test + fun `translation is diacritic insensitive`() { + val result = useCase(ex(ExerciseType.TRANSLATE_EN_SK, "Ďakujem"), "dakujem") + assertTrue(result.correct) + } + + @Test + fun `word bank is word-order tolerant`() { + val e = ex(ExerciseType.WORD_BANK, "Ako sa máš") + assertTrue(useCase.isCorrect(e, "máš sa ako")) + } + + @Test + fun `hint downgrades a correct answer to hard`() { + val result = useCase(ex(ExerciseType.MCQ, "ahoj"), "ahoj", hintUsed = true) + assertEquals(Grade.HARD, result.grade) + } +} diff --git a/slovko/app/src/test/java/com/slovko/domain/usecase/XpCalculatorTest.kt b/slovko/app/src/test/java/com/slovko/domain/usecase/XpCalculatorTest.kt new file mode 100644 index 0000000..7f12e77 --- /dev/null +++ b/slovko/app/src/test/java/com/slovko/domain/usecase/XpCalculatorTest.kt @@ -0,0 +1,44 @@ +package com.slovko.domain.usecase + +import com.slovko.domain.model.ExerciseType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class XpCalculatorTest { + + @Test + fun `perfect lesson adds the perfect bonus`() { + val results = List(6) { ExerciseType.MCQ to true } + val b = XpCalculator.lessonXp(results, fast = false, firstOfDay = false, challenge = false) + assertTrue(b.perfectBonus > 0) + assertTrue(b.total > b.base) + } + + @Test + fun `a lesson with a mistake earns no perfect bonus`() { + val results = listOf( + ExerciseType.MCQ to true, ExerciseType.MCQ to false, ExerciseType.MCQ to true, + ) + val b = XpCalculator.lessonXp(results, fast = false, firstOfDay = false, challenge = false) + assertEquals(0, b.perfectBonus) + } + + @Test + fun `challenge mode multiplies earned xp`() { + val results = List(5) { ExerciseType.MCQ to true } + val normal = XpCalculator.lessonXp(results, false, false, challenge = false) + val challenge = XpCalculator.lessonXp(results, false, false, challenge = true) + assertTrue(challenge.total > normal.total) + } + + @Test + fun `level curve is monotonic increasing`() { + var prev = -1 + for (lvl in 1..10) { + val xp = com.slovko.domain.GamificationConfig.cumulativeXpForLevel(lvl) + assertTrue(xp >= prev) + prev = xp + } + } +} From bb29d93f559668419ce92a5e3b6121644dcba8a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:04:26 +0000 Subject: [PATCH 3/6] Slovko: all feature screens + full Slovak curriculum content 10 Compose feature screens (onboarding, home/skill-tree, lesson player, chat hub, chat, phrasebook, profile, achievements, leaderboard, settings) and the bundled curriculum: 13 units, 26 skills, ~333 exercises, ~209 vocab cards, 8 chat scenarios, 46 texting phrases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KcutcC6MjorhCn8EAfHDcP --- .../main/assets/curriculum/phrasebook.json | 50 ++ .../src/main/assets/curriculum/scenarios.json | 563 ++++++++++++++++++ .../main/assets/curriculum/unit0-zvuky.json | 328 ++++++++++ .../main/assets/curriculum/unit1-ahoj.json | 356 +++++++++++ .../assets/curriculum/unit10-praca-skola.json | 382 ++++++++++++ .../assets/curriculum/unit11-cestovanie.json | 361 +++++++++++ .../assets/curriculum/unit12-piseme-si.json | 387 ++++++++++++ .../main/assets/curriculum/unit2-ja-a-ty.json | 354 +++++++++++ .../main/assets/curriculum/unit3-rodina.json | 358 +++++++++++ .../assets/curriculum/unit4-jedlo-kava.json | 392 ++++++++++++ .../main/assets/curriculum/unit5-v-meste.json | 394 ++++++++++++ .../assets/curriculum/unit6-cas-plany.json | 360 +++++++++++ .../assets/curriculum/unit7-kazdy-den.json | 380 ++++++++++++ .../assets/curriculum/unit8-nakupovanie.json | 392 ++++++++++++ .../curriculum/unit9-pocasie-pocity.json | 383 ++++++++++++ .../ui/achievements/AchievementsScreen.kt | 177 ++++++ .../ui/achievements/AchievementsViewModel.kt | 25 + .../java/com/slovko/ui/chat/ChatScreen.kt | 305 ++++++++++ .../java/com/slovko/ui/chat/ChatViewModel.kt | 98 +++ .../com/slovko/ui/chat/PhrasebookScreen.kt | 240 ++++++++ .../com/slovko/ui/chat/PhrasebookViewModel.kt | 56 ++ .../com/slovko/ui/chathub/ChatHubScreen.kt | 201 +++++++ .../com/slovko/ui/chathub/ChatHubViewModel.kt | 25 + .../ui/leaderboard/LeaderboardScreen.kt | 177 ++++++ .../ui/leaderboard/LeaderboardViewModel.kt | 25 + .../java/com/slovko/ui/lesson/LessonScreen.kt | 358 +++++++++++ .../com/slovko/ui/lesson/LessonViewModel.kt | 247 ++++++++ .../slovko/ui/lesson/exercise/Exercises.kt | 275 +++++++++ .../com/slovko/ui/profile/ProfileScreen.kt | 313 ++++++++++ .../com/slovko/ui/profile/ProfileViewModel.kt | 48 ++ .../com/slovko/ui/settings/SettingsScreen.kt | 316 ++++++++++ .../slovko/ui/settings/SettingsViewModel.kt | 33 + 32 files changed, 8359 insertions(+) create mode 100644 slovko/app/src/main/assets/curriculum/phrasebook.json create mode 100644 slovko/app/src/main/assets/curriculum/scenarios.json create mode 100644 slovko/app/src/main/assets/curriculum/unit0-zvuky.json create mode 100644 slovko/app/src/main/assets/curriculum/unit1-ahoj.json create mode 100644 slovko/app/src/main/assets/curriculum/unit10-praca-skola.json create mode 100644 slovko/app/src/main/assets/curriculum/unit11-cestovanie.json create mode 100644 slovko/app/src/main/assets/curriculum/unit12-piseme-si.json create mode 100644 slovko/app/src/main/assets/curriculum/unit2-ja-a-ty.json create mode 100644 slovko/app/src/main/assets/curriculum/unit3-rodina.json create mode 100644 slovko/app/src/main/assets/curriculum/unit4-jedlo-kava.json create mode 100644 slovko/app/src/main/assets/curriculum/unit5-v-meste.json create mode 100644 slovko/app/src/main/assets/curriculum/unit6-cas-plany.json create mode 100644 slovko/app/src/main/assets/curriculum/unit7-kazdy-den.json create mode 100644 slovko/app/src/main/assets/curriculum/unit8-nakupovanie.json create mode 100644 slovko/app/src/main/assets/curriculum/unit9-pocasie-pocity.json create mode 100644 slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/chat/ChatViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/profile/ProfileScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/profile/ProfileViewModel.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/settings/SettingsScreen.kt create mode 100644 slovko/app/src/main/java/com/slovko/ui/settings/SettingsViewModel.kt diff --git a/slovko/app/src/main/assets/curriculum/phrasebook.json b/slovko/app/src/main/assets/curriculum/phrasebook.json new file mode 100644 index 0000000..bf3e966 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/phrasebook.json @@ -0,0 +1,50 @@ +{ + "phrases": [ + { "id": "pb-cau", "sk": "čau", "en": "hi / bye", "register": "informal", "note": "Works for both greeting and goodbye. Most common casual hello in texts.", "orderIndex": 0 }, + { "id": "pb-ahoj", "sk": "ahoj", "en": "hi / bye", "register": "informal", "note": "Classic informal greeting, also used to say goodbye.", "orderIndex": 1 }, + { "id": "pb-caute", "sk": "čaute", "en": "hi (to a group)", "register": "informal", "note": "Plural of čau, used when greeting several people, e.g. in a group chat.", "orderIndex": 2 }, + { "id": "pb-co-robis", "sk": "čo robíš?", "en": "what are you doing?", "register": "informal", "note": "Standard opener to start a chat.", "orderIndex": 3 }, + { "id": "pb-co-robis-short", "sk": "co robis?", "en": "whatcha doing?", "register": "informal", "note": "Diacritic-dropped form typical of fast texting. Same as 'čo robíš?'.", "orderIndex": 4 }, + { "id": "pb-ako-slo", "sk": "ako šlo?", "en": "how did it go?", "register": "informal", "note": "Ask about an event, exam, interview, etc.", "orderIndex": 5 }, + { "id": "pb-ako-bolo", "sk": "ako bolo?", "en": "how was it?", "register": "informal", "note": "Ask how something was, e.g. a trip or party.", "orderIndex": 6 }, + { "id": "pb-co-noveho", "sk": "čo nové?", "en": "what's new?", "register": "informal", "note": "Casual catch-up question.", "orderIndex": 7 }, + { "id": "pb-si-tam", "sk": "si tam?", "en": "you there?", "register": "informal", "note": "Check if someone is online or still around.", "orderIndex": 8 }, + { "id": "pb-kde-si", "sk": "kde si?", "en": "where are you?", "register": "informal", "note": "Ask someone's location.", "orderIndex": 9 }, + { "id": "pb-daj-vediet", "sk": "daj vedieť", "en": "let me know", "register": "informal", "note": "Ask someone to update you. Very common in plans.", "orderIndex": 10 }, + { "id": "pb-napis-mi", "sk": "napíš mi", "en": "text me", "register": "informal", "note": "Literally 'write me'.", "orderIndex": 11 }, + { "id": "pb-ozvi-sa", "sk": "ozvi sa", "en": "get in touch", "register": "informal", "note": "Ask someone to contact you later.", "orderIndex": 12 }, + { "id": "pb-dohodnute", "sk": "dohodnuté", "en": "deal / agreed", "register": "informal", "note": "Confirms a plan is set.", "orderIndex": 13 }, + { "id": "pb-plati", "sk": "platí", "en": "it's a deal / sounds good", "register": "informal", "note": "Confirms agreement, like 'done'.", "orderIndex": 14 }, + { "id": "pb-hej", "sk": "hej", "en": "yeah", "register": "informal", "note": "Casual yes.", "orderIndex": 15 }, + { "id": "pb-jasne", "sk": "jasné", "en": "sure / got it", "register": "informal", "note": "Agreement or understanding.", "orderIndex": 16 }, + { "id": "pb-jasnacka", "sk": "jasnačka", "en": "sure thing", "register": "informal", "note": "Playful, emphatic version of 'jasné'.", "orderIndex": 17 }, + { "id": "pb-v-pohode", "sk": "v pohode", "en": "it's cool / no problem", "register": "informal", "note": "Extremely common. Means fine, okay, no worries.", "orderIndex": 18 }, + { "id": "pb-v-pohode-short", "sk": "vpohode", "en": "np / all good", "register": "informal", "note": "Run-together texting spelling of 'v pohode'.", "orderIndex": 19 }, + { "id": "pb-no-nevadi", "sk": "no nevadí", "en": "ah, never mind", "register": "informal", "note": "Brush off a small problem.", "orderIndex": 20 }, + { "id": "pb-to-nic", "sk": "to nič", "en": "it's nothing / no biggie", "register": "informal", "note": "Reassure after an apology or thanks.", "orderIndex": 21 }, + { "id": "pb-vdaka", "sk": "vďaka", "en": "thanks", "register": "informal", "note": "Short, casual thank you.", "orderIndex": 22 }, + { "id": "pb-dik", "sk": "dik", "en": "thx", "register": "informal", "note": "Even shorter thanks, very texty. Also spelled 'ďik'.", "orderIndex": 23 }, + { "id": "pb-vdakaaa", "sk": "vďakaaa", "en": "thankss", "register": "informal", "note": "Vowel-stretching shows extra warmth or enthusiasm.", "orderIndex": 24 }, + { "id": "pb-meskam", "sk": "meškám trochu", "en": "I'm running a bit late", "register": "informal", "note": "Heads-up when you'll be late.", "orderIndex": 25 }, + { "id": "pb-uz-idem", "sk": "už idem", "en": "on my way", "register": "informal", "note": "Tell someone you're coming.", "orderIndex": 26 }, + { "id": "pb-hned-som-tam", "sk": "hneď som tam", "en": "be right there", "register": "informal", "note": "You'll arrive any moment.", "orderIndex": 27 }, + { "id": "pb-sorry", "sk": "sorry", "en": "sorry", "register": "informal", "note": "English borrowing, very widely used by younger Slovaks.", "orderIndex": 28 }, + { "id": "pb-prepac", "sk": "prepáč", "en": "sorry / excuse me", "register": "informal", "note": "Native apology, slightly more sincere than 'sorry'.", "orderIndex": 29 }, + { "id": "pb-pls", "sk": "pls", "en": "please", "register": "informal", "note": "English-borrowed shorthand for 'prosím'.", "orderIndex": 30 }, + { "id": "pb-vidime-sa", "sk": "vidíme sa", "en": "see you", "register": "informal", "note": "Casual sign-off when you'll meet soon.", "orderIndex": 31 }, + { "id": "pb-tak-zatial", "sk": "tak zatiaľ", "en": "see you later", "register": "informal", "note": "Literally 'so, for now'. Friendly goodbye.", "orderIndex": 32 }, + { "id": "pb-maj-sa", "sk": "maj sa", "en": "take care", "register": "informal", "note": "Warm goodbye, short for 'maj sa dobre'.", "orderIndex": 33 }, + { "id": "pb-pa", "sk": "pa", "en": "bye", "register": "informal", "note": "Cute, short goodbye. Often doubled as 'papa'.", "orderIndex": 34 }, + { "id": "pb-papa", "sk": "papa", "en": "byebye", "register": "informal", "note": "Affectionate goodbye, common with friends and family.", "orderIndex": 35 }, + { "id": "pb-haha", "sk": "haha", "en": "haha", "register": "informal", "note": "Laughter in text.", "orderIndex": 36 }, + { "id": "pb-xd", "sk": "xd", "en": "lol / xD", "register": "informal", "note": "Laughing emoticon, often typed lowercase.", "orderIndex": 37 }, + { "id": "pb-no", "sk": "no", "en": "well / yeah (filler)", "register": "informal", "note": "Ubiquitous filler. 'No' can mean yes, well, so, depending on tone. Not the English 'no'.", "orderIndex": 38 }, + { "id": "pb-no-hej", "sk": "no hej", "en": "well, yeah", "register": "informal", "note": "Reluctant or thoughtful agreement.", "orderIndex": 39 }, + { "id": "pb-tak-co", "sk": "tak čo?", "en": "so what's up?", "register": "informal", "note": "Nudges someone for news or a decision.", "orderIndex": 40 }, + { "id": "pb-ide-to", "sk": "ide to", "en": "doing okay", "register": "informal", "note": "Casual answer to 'how are you'. Also 'ujde to'.", "orderIndex": 41 }, + { "id": "pb-super", "sk": "super", "en": "great / awesome", "register": "informal", "note": "All-purpose enthusiastic reaction.", "orderIndex": 42 }, + { "id": "pb-fakt", "sk": "fakt?", "en": "for real?", "register": "informal", "note": "Express surprise or ask for confirmation.", "orderIndex": 43 }, + { "id": "pb-ahojkyyy", "sk": "ahojkyyy", "en": "hiii", "register": "informal", "note": "Diminutive 'ahojky' with vowel-stretching for a cheerful, affectionate tone.", "orderIndex": 44 }, + { "id": "pb-dakujem-short", "sk": "dakujem", "en": "thank you", "register": "informal", "note": "Diacritic-dropped 'ďakujem', typical when typing fast without a Slovak keyboard.", "orderIndex": 45 } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/scenarios.json b/slovko/app/src/main/assets/curriculum/scenarios.json new file mode 100644 index 0000000..87ea253 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/scenarios.json @@ -0,0 +1,563 @@ +{ + "scenarios": [ + { + "id": "coffee-with-friend", + "title": "Coffee with a friend", + "description": "Catch up with a friend over coffee. Order a drink and chat about how you're doing.", + "cefrLevel": "A1", + "starterLineSk": "Čau! Tak čo, dáš si kávu?", + "iconKey": "coffee", + "locked": false, + "orderIndex": 0, + "turns": [ + { + "role": "partner", + "sk": "Čau! Tak čo, dáš si kávu?", + "enGloss": "Hi! So, will you have a coffee?", + "replies": [ + { "sk": "Jasné, dám si kávu.", "enGloss": "Sure, I'll have a coffee.", "natural": true }, + { "sk": "Hej, prosím si kapučíno.", "enGloss": "Yeah, I'd like a cappuccino.", "natural": true }, + { "sk": "Nie, ja som strom.", "enGloss": "No, I am a tree.", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Super. A ako sa máš? Dlho sme sa nevideli.", + "enGloss": "Great. And how are you? We haven't seen each other in a while.", + "replies": [ + { "sk": "Mám sa dobre, vďaka. A ty?", "enGloss": "I'm good, thanks. And you?", "natural": true }, + { "sk": "Ujde to, dosť veľa roboty.", "enGloss": "Not bad, quite a lot of work.", "natural": true }, + { "sk": "Mám sa modrý.", "enGloss": "I am blue (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Ja tiež veľa makám. Čo je u teba nové?", + "enGloss": "I also work a lot. What's new with you?", + "replies": [ + { "sk": "Nič moc, všetko po starom.", "enGloss": "Not much, everything's the same.", "natural": true }, + { "sk": "Začal som chodiť do fitka.", "enGloss": "I started going to the gym.", "natural": true }, + { "sk": "Áno deväť.", "enGloss": "Yes nine.", "natural": false } + ] + }, + { + "role": "partner", + "sk": "To je super! Ja by som tiež mal viac cvičiť.", + "enGloss": "That's great! I should exercise more too.", + "replies": [ + { "sk": "Poď so mnou niekedy!", "enGloss": "Come with me sometime!", "natural": true }, + { "sk": "Hej, treba sa hýbať.", "enGloss": "Yeah, you've got to move.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Dobrý nápad. Mimochodom, máš plány na víkend?", + "enGloss": "Good idea. By the way, do you have plans for the weekend?", + "replies": [ + { "sk": "Zatiaľ nič, uvidíme.", "enGloss": "Nothing yet, we'll see.", "natural": true }, + { "sk": "Idem na chalupu k rodičom.", "enGloss": "I'm going to the cottage to my parents'.", "natural": true }, + { "sk": "Víkend je kameň.", "enGloss": "The weekend is a stone.", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Tak fajn. Už musím ísť, mám stretnutie. Maj sa!", + "enGloss": "Alright then. I have to go, I have a meeting. Take care!", + "replies": [ + { "sk": "Maj sa, ozvi sa!", "enGloss": "Take care, get in touch!", "natural": true }, + { "sk": "Čau, vidíme sa!", "enGloss": "Bye, see you!", "natural": true }, + { "sk": "Maj sa dom.", "enGloss": "Have a house (nonsense).", "natural": false } + ] + } + ] + }, + { + "id": "texting-plans", + "title": "Texting plans", + "description": "Make plans to meet up over text. Pick a time and place.", + "cefrLevel": "A1", + "starterLineSk": "Ahoj, čo robíš večer?", + "iconKey": "phone", + "locked": false, + "orderIndex": 1, + "turns": [ + { + "role": "partner", + "sk": "Ahoj, čo robíš večer?", + "enGloss": "Hi, what are you doing tonight?", + "replies": [ + { "sk": "Nič extra, prečo?", "enGloss": "Nothing special, why?", "natural": true }, + { "sk": "Zatiaľ nemám plán.", "enGloss": "No plan yet.", "natural": true }, + { "sk": "Robím auto.", "enGloss": "I'm doing a car (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Nedáme si niekde pivo?", + "enGloss": "Shall we grab a beer somewhere?", + "replies": [ + { "sk": "Jasné, kam pôjdeme?", "enGloss": "Sure, where shall we go?", "natural": true }, + { "sk": "Hej, super nápad!", "enGloss": "Yeah, great idea!", "natural": true }, + { "sk": "Pivo je smutné.", "enGloss": "Beer is sad (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Čo tak ten bar na námestí? O siedmej?", + "enGloss": "How about that bar on the square? At seven?", + "replies": [ + { "sk": "Dohodnuté, o siedmej tam som.", "enGloss": "Deal, I'll be there at seven.", "natural": true }, + { "sk": "Môže byť skôr? Tak o šiestej?", "enGloss": "Can it be earlier? Around six?", "natural": true }, + { "sk": "Siedma je zelená.", "enGloss": "Seven is green (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "O šiestej mi to vyhovuje. Tak o šiestej.", + "enGloss": "Six works for me. So at six then.", + "replies": [ + { "sk": "Super, teším sa!", "enGloss": "Great, looking forward to it!", "natural": true }, + { "sk": "Dobre, dám ti vedieť, ak budem meškať.", "enGloss": "Okay, I'll let you know if I'm late.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Vezmi aj Petra, ak chce prísť.", + "enGloss": "Bring Peter too, if he wants to come.", + "replies": [ + { "sk": "Dobre, napíšem mu.", "enGloss": "Okay, I'll text him.", "natural": true }, + { "sk": "Jasné, spýtam sa ho.", "enGloss": "Sure, I'll ask him.", "natural": true }, + { "sk": "Peter je dvere.", "enGloss": "Peter is a door (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Paráda. Tak sa vidíme večer!", + "enGloss": "Awesome. So see you tonight!", + "replies": [ + { "sk": "Vidíme sa, čau!", "enGloss": "See you, bye!", "natural": true }, + { "sk": "Tak zatiaľ, maj sa!", "enGloss": "See you later, take care!", "natural": true } + ] + } + ] + }, + { + "id": "running-late", + "title": "Running late", + "description": "You're late to meet someone. Apologize and explain.", + "cefrLevel": "A1", + "starterLineSk": "Si už na ceste? Čakám pred kinom.", + "iconKey": "clock", + "locked": false, + "orderIndex": 2, + "turns": [ + { + "role": "partner", + "sk": "Si už na ceste? Čakám pred kinom.", + "enGloss": "Are you on your way? I'm waiting in front of the cinema.", + "replies": [ + { "sk": "Sorry, trochu meškám.", "enGloss": "Sorry, I'm a bit late.", "natural": true }, + { "sk": "Už idem, prepáč!", "enGloss": "I'm coming, sorry!", "natural": true }, + { "sk": "Som v kine doma.", "enGloss": "I'm in the cinema at home (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Aha, a kedy tu zhruba budeš?", + "enGloss": "Oh, and roughly when will you be here?", + "replies": [ + { "sk": "Asi o desať minút.", "enGloss": "In about ten minutes.", "natural": true }, + { "sk": "Hneď som tam, fakt sorry.", "enGloss": "I'll be there right away, really sorry.", "natural": true }, + { "sk": "Budem tam včera.", "enGloss": "I'll be there yesterday (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Čo sa stalo? Zaspal si?", + "enGloss": "What happened? Did you oversleep?", + "replies": [ + { "sk": "Meškal mi autobus.", "enGloss": "My bus was late.", "natural": true }, + { "sk": "Bola zápcha, hrozné.", "enGloss": "There was traffic, terrible.", "natural": true }, + { "sk": "Autobus je polievka.", "enGloss": "The bus is soup (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Nevadí, počkám. Film aj tak začína o pol.", + "enGloss": "No worries, I'll wait. The film starts at half past anyway.", + "replies": [ + { "sk": "Super, ešte to stihneme.", "enGloss": "Great, we'll still make it.", "natural": true }, + { "sk": "Vďaka, že čakáš!", "enGloss": "Thanks for waiting!", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Daj vedieť, keď budeš blízko.", + "enGloss": "Let me know when you're close.", + "replies": [ + { "sk": "Jasné, napíšem ti.", "enGloss": "Sure, I'll text you.", "natural": true }, + { "sk": "Dobre, už zabáčam.", "enGloss": "Okay, I'm turning the corner now.", "natural": true }, + { "sk": "Blízko je ďaleko.", "enGloss": "Close is far (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Vidím ťa! Konečne. Poď, ideme dnu.", + "enGloss": "I see you! Finally. Come on, let's go in.", + "replies": [ + { "sk": "Ešte raz prepáč za meškanie.", "enGloss": "Sorry again for being late.", "natural": true }, + { "sk": "Som tu, poďme!", "enGloss": "I'm here, let's go!", "natural": true } + ] + } + ] + }, + { + "id": "weekend-recap", + "title": "Weekend recap", + "description": "Tell a friend how your weekend went and ask about theirs.", + "cefrLevel": "A2", + "starterLineSk": "Ahoj, ako bolo cez víkend?", + "iconKey": "calendar", + "locked": false, + "orderIndex": 3, + "turns": [ + { + "role": "partner", + "sk": "Ahoj, ako bolo cez víkend?", + "enGloss": "Hi, how was the weekend?", + "replies": [ + { "sk": "Bolo super, oddýchol som si.", "enGloss": "It was great, I had a rest.", "natural": true }, + { "sk": "Celkom fajn, a u teba?", "enGloss": "Pretty good, and you?", "natural": true }, + { "sk": "Víkend bol stôl.", "enGloss": "The weekend was a table (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Aj mne ušlo to dobre. Čo si robil?", + "enGloss": "Mine went well too. What did you do?", + "replies": [ + { "sk": "Boli sme s rodinou na výlete.", "enGloss": "We went on a trip with the family.", "natural": true }, + { "sk": "Hlavne som spal a pozeral seriály.", "enGloss": "Mostly I slept and watched shows.", "natural": true }, + { "sk": "Robil som oblak.", "enGloss": "I made a cloud (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Pekné! A kam ste šli na ten výlet?", + "enGloss": "Nice! And where did you go on that trip?", + "replies": [ + { "sk": "Do Tatier, bolo tam krásne.", "enGloss": "To the Tatras, it was beautiful there.", "natural": true }, + { "sk": "K babke na dedinu.", "enGloss": "To grandma's in the village.", "natural": true }, + { "sk": "Šli sme do vidličky.", "enGloss": "We went into a fork (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Tatry milujem. Aké bolo počasie?", + "enGloss": "I love the Tatras. What was the weather like?", + "replies": [ + { "sk": "Slnečno, ale dosť zima.", "enGloss": "Sunny, but quite cold.", "natural": true }, + { "sk": "Pršalo, takže sme veľa nešli.", "enGloss": "It rained, so we didn't walk much.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Škoda toho dažďa. Aspoň ste si oddýchli, nie?", + "enGloss": "Shame about the rain. At least you rested, right?", + "replies": [ + { "sk": "Hej, to hej. Stálo to za to.", "enGloss": "Yeah, that's true. It was worth it.", "natural": true }, + { "sk": "Presne, potrebovali sme to.", "enGloss": "Exactly, we needed it.", "natural": true }, + { "sk": "Dážď je rýchly.", "enGloss": "Rain is fast (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Tak nabudúce poďme spolu niekam.", + "enGloss": "So next time let's go somewhere together.", + "replies": [ + { "sk": "Jasné, rád pôjdem!", "enGloss": "Sure, I'll gladly go!", "natural": true }, + { "sk": "Dohodnuté, naplánujeme to.", "enGloss": "Deal, we'll plan it.", "natural": true } + ] + } + ] + }, + { + "id": "at-the-pub", + "title": "At the pub", + "description": "Order drinks and food at a pub and chat with the waiter.", + "cefrLevel": "A2", + "starterLineSk": "Dobrý deň, čo si dáte?", + "iconKey": "pub", + "locked": true, + "orderIndex": 4, + "turns": [ + { + "role": "partner", + "sk": "Dobrý deň, čo si dáte?", + "enGloss": "Hello, what would you like?", + "replies": [ + { "sk": "Dám si jedno veľké pivo, prosím.", "enGloss": "I'll have one large beer, please.", "natural": true }, + { "sk": "Prosím si kofolu.", "enGloss": "I'd like a Kofola.", "natural": true }, + { "sk": "Dám si stoličku.", "enGloss": "I'll have a chair (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Jasné. A dáte si aj niečo pod zub?", + "enGloss": "Sure. And will you have something to eat too?", + "replies": [ + { "sk": "Áno, máte syrové prsty?", "enGloss": "Yes, do you have cheese sticks?", "natural": true }, + { "sk": "Dám si bryndzové halušky.", "enGloss": "I'll have bryndza halušky.", "natural": true }, + { "sk": "Zjem ten stôl.", "enGloss": "I'll eat that table (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Výborná voľba. Niečo na pitie ešte?", + "enGloss": "Excellent choice. Anything else to drink?", + "replies": [ + { "sk": "Nie, zatiaľ stačí, vďaka.", "enGloss": "No, that's enough for now, thanks.", "natural": true }, + { "sk": "Ešte vodu, prosím.", "enGloss": "Some water too, please.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Nech sa páči, vaše pivo. Dobrú chuť!", + "enGloss": "Here you go, your beer. Enjoy!", + "replies": [ + { "sk": "Ďakujem pekne!", "enGloss": "Thank you very much!", "natural": true }, + { "sk": "Vďaka, na zdravie!", "enGloss": "Thanks, cheers!", "natural": true }, + { "sk": "Pivo je sused.", "enGloss": "The beer is a neighbour (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Chutilo vám? Môžem doniesť účet?", + "enGloss": "Did you enjoy it? Can I bring the bill?", + "replies": [ + { "sk": "Áno, výborné. Účet, prosím.", "enGloss": "Yes, excellent. The bill, please.", "natural": true }, + { "sk": "Zaplatím kartou.", "enGloss": "I'll pay by card.", "natural": true }, + { "sk": "Účet je modrý.", "enGloss": "The bill is blue (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Bude to dvanásť eur. Ďakujem a dovidenia!", + "enGloss": "That'll be twelve euros. Thank you and goodbye!", + "replies": [ + { "sk": "Nech sa páči, zvyšok je váš.", "enGloss": "Here you go, keep the change.", "natural": true }, + { "sk": "Ďakujem, dovidenia!", "enGloss": "Thank you, goodbye!", "natural": true } + ] + } + ] + }, + { + "id": "asking-a-favor", + "title": "Asking a favor", + "description": "Ask a friend for a small favor and arrange the details.", + "cefrLevel": "A2", + "starterLineSk": "Čau, môžem ťa o niečo poprosiť?", + "iconKey": "chat", + "locked": true, + "orderIndex": 5, + "turns": [ + { + "role": "partner", + "sk": "Čau, môžem ťa o niečo poprosiť?", + "enGloss": "Hi, can I ask you for something?", + "replies": [ + { "sk": "Jasné, čo potrebuješ?", "enGloss": "Sure, what do you need?", "natural": true }, + { "sk": "Hovor, počúvam.", "enGloss": "Go ahead, I'm listening.", "natural": true }, + { "sk": "Nie, som lampa.", "enGloss": "No, I'm a lamp (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Mohol by si ma zajtra odviezť na stanicu?", + "enGloss": "Could you give me a ride to the station tomorrow?", + "replies": [ + { "sk": "Jasné, o koľkej?", "enGloss": "Sure, at what time?", "natural": true }, + { "sk": "Hej, žiaden problém.", "enGloss": "Yeah, no problem.", "natural": true }, + { "sk": "Stanica je mokrá.", "enGloss": "The station is wet (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Vlak mi ide o ôsmej, takže tak o pol ôsmej?", + "enGloss": "My train leaves at eight, so around half past seven?", + "replies": [ + { "sk": "V pohode, vyzdvihnem ťa o pol ôsmej.", "enGloss": "No problem, I'll pick you up at half past seven.", "natural": true }, + { "sk": "Dobre, budem pred domom.", "enGloss": "Okay, I'll be in front of the house.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Si zlatý, fakt vďaka. Dlžím ti kávu.", + "enGloss": "You're a star, thanks a lot. I owe you a coffee.", + "replies": [ + { "sk": "Niet za čo, kľudne.", "enGloss": "Don't mention it, no worries.", "natural": true }, + { "sk": "Tak na tú kávu sa teším!", "enGloss": "I'm looking forward to that coffee then!", "natural": true }, + { "sk": "Káva je pondelok.", "enGloss": "Coffee is Monday (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Ešte raz vďaka. Tak zajtra ráno?", + "enGloss": "Thanks again. So tomorrow morning?", + "replies": [ + { "sk": "Áno, ráno o pol ôsmej.", "enGloss": "Yes, in the morning at half past seven.", "natural": true }, + { "sk": "Platí, daj vedieť, ak sa niečo zmení.", "enGloss": "Deal, let me know if anything changes.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Super. Tak sa maj, dobrú noc!", + "enGloss": "Great. Take care then, good night!", + "replies": [ + { "sk": "Dobrú noc, vidíme sa ráno!", "enGloss": "Good night, see you in the morning!", "natural": true }, + { "sk": "Pa, dobre sa vyspi!", "enGloss": "Bye, sleep well!", "natural": true } + ] + } + ] + }, + { + "id": "meeting-the-family", + "title": "Meeting the family", + "description": "Meet your partner's family for the first time. Be polite and friendly.", + "cefrLevel": "A2", + "starterLineSk": "Dobrý deň, teší ma. Konečne sa stretávame!", + "iconKey": "family", + "locked": true, + "orderIndex": 6, + "turns": [ + { + "role": "partner", + "sk": "Dobrý deň, teší ma. Konečne sa stretávame!", + "enGloss": "Hello, nice to meet you. We finally meet!", + "replies": [ + { "sk": "Teší ma, veľa som o vás počul.", "enGloss": "Nice to meet you, I've heard a lot about you.", "natural": true }, + { "sk": "Dobrý deň, aj mňa teší.", "enGloss": "Hello, the pleasure is mine.", "natural": true }, + { "sk": "Ahoj, som hladný stôl.", "enGloss": "Hi, I'm a hungry table (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Poďte ďalej, nech sa páči. Dáte si kávu alebo čaj?", + "enGloss": "Come in, please. Will you have coffee or tea?", + "replies": [ + { "sk": "Kávu, ak môžem. Ďakujem.", "enGloss": "Coffee, if I may. Thank you.", "natural": true }, + { "sk": "Čaj by bol super, vďaka.", "enGloss": "Tea would be great, thanks.", "natural": true }, + { "sk": "Dám si vašu mačku.", "enGloss": "I'll have your cat (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "A čo robíte? Kde pracujete?", + "enGloss": "And what do you do? Where do you work?", + "replies": [ + { "sk": "Pracujem ako učiteľ na základnej škole.", "enGloss": "I work as a teacher at an elementary school.", "natural": true }, + { "sk": "Robím v IT, programujem.", "enGloss": "I work in IT, I program.", "natural": true }, + { "sk": "Pracujem ako oblak.", "enGloss": "I work as a cloud (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "To je pekné povolanie. A odkiaľ pochádzate?", + "enGloss": "That's a nice profession. And where are you from?", + "replies": [ + { "sk": "Som z Košíc, ale bývam v Bratislave.", "enGloss": "I'm from Košice, but I live in Bratislava.", "natural": true }, + { "sk": "Pochádzam z malej dediny pri Žiline.", "enGloss": "I come from a small village near Žilina.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Krásne. Tak si sadnite, obed je takmer hotový.", + "enGloss": "Lovely. So sit down, lunch is almost ready.", + "replies": [ + { "sk": "Ďakujem, vonia to skvele!", "enGloss": "Thank you, it smells amazing!", "natural": true }, + { "sk": "Veľmi pekné, nemuseli ste sa toľko trápiť.", "enGloss": "Very kind, you shouldn't have gone to such trouble.", "natural": true }, + { "sk": "Obed je smutný.", "enGloss": "Lunch is sad (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Ale prosím vás, je to radosť. Dobrú chuť!", + "enGloss": "Oh please, it's a pleasure. Enjoy your meal!", + "replies": [ + { "sk": "Ďakujem pekne, dobrú chuť aj vám.", "enGloss": "Thank you very much, enjoy your meal too.", "natural": true }, + { "sk": "Vyzerá to výborne, ďakujem!", "enGloss": "It looks excellent, thank you!", "natural": true } + ] + } + ] + }, + { + "id": "group-chat-reactions", + "title": "Group-chat reactions", + "description": "React in a lively group chat. Keep it short and casual.", + "cefrLevel": "A2", + "starterLineSk": "Ľudia, idem dnes na grilovačku. Kto príde?", + "iconKey": "phone", + "locked": true, + "orderIndex": 7, + "turns": [ + { + "role": "partner", + "sk": "Ľudia, idem dnes na grilovačku. Kto príde?", + "enGloss": "Guys, I'm having a barbecue today. Who's coming?", + "replies": [ + { "sk": "Ja idem! O koľkej?", "enGloss": "I'm in! At what time?", "natural": true }, + { "sk": "Jasné, počítaj so mnou.", "enGloss": "Sure, count me in.", "natural": true }, + { "sk": "Gril je pondelok.", "enGloss": "The grill is Monday (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Super! Začíname o piatej u mňa na záhrade.", + "enGloss": "Great! We start at five at my place in the garden.", + "replies": [ + { "sk": "Top, prinesiem pivo.", "enGloss": "Awesome, I'll bring beer.", "natural": true }, + { "sk": "Mám priniesť niečo?", "enGloss": "Should I bring something?", "natural": true }, + { "sk": "Záhrada je hlasná.", "enGloss": "The garden is loud (nonsense, misspelled).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Mäso mám, doneste hlavne chlast a dobrú náladu :D", + "enGloss": "I've got the meat, mainly bring booze and good vibes :D", + "replies": [ + { "sk": "Haha jasné, nálada bude!", "enGloss": "Haha sure, the vibes will be there!", "natural": true }, + { "sk": "V pohode, ja kúpim šaláty.", "enGloss": "No problem, I'll buy salads.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Niekto vie, či bude pršať? Pozeral som a vraj nie.", + "enGloss": "Does anyone know if it'll rain? I checked and apparently not.", + "replies": [ + { "sk": "Aj ja pozeral, malo by byť super počasie.", "enGloss": "I checked too, the weather should be great.", "natural": true }, + { "sk": "Dúfajme, že nie!", "enGloss": "Let's hope not!", "natural": true }, + { "sk": "Dážď je rýchly stôl.", "enGloss": "Rain is a fast table (nonsense).", "natural": false } + ] + }, + { + "role": "partner", + "sk": "Tak fajn, čakám vás. Kto vie, nech vezme aj reproák.", + "enGloss": "Alright, I'll be expecting you. Whoever can, bring a speaker too.", + "replies": [ + { "sk": "Reproák beriem ja!", "enGloss": "I'll bring the speaker!", "natural": true }, + { "sk": "Mám doma, dovlečiem ho.", "enGloss": "I have one at home, I'll drag it over.", "natural": true } + ] + }, + { + "role": "partner", + "sk": "Paráda, ste najlepší. Vidíme sa o piatej! 🔥", + "enGloss": "Awesome, you're the best. See you at five! 🔥", + "replies": [ + { "sk": "Tešíme sa, vidíme sa!", "enGloss": "Looking forward to it, see you!", "natural": true }, + { "sk": "Idemeee! 🔥", "enGloss": "Let's gooo! 🔥", "natural": true } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit0-zvuky.json b/slovko/app/src/main/assets/curriculum/unit0-zvuky.json new file mode 100644 index 0000000..bb2a58d --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit0-zvuky.json @@ -0,0 +1,328 @@ +{ + "unit": { "id": "u0", "name": "Zvuky", "cefr": "A1", "order": 0 }, + "skills": [ + { + "id": "s0-abeceda", + "unitId": "u0", + "title": "Abeceda a diakritika", + "description": "Slovak alphabet and the diacritical marks: dĺžeň (á é í ...), mäkčeň (š č ž ...), and dve bodky (ä).", + "iconKey": "sound", + "colorKey": "blue", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [], + "lessons": [ + { + "id": "l0-abeceda-teach", + "title": "Hlásky a dĺžne", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e0-abc-1", + "orderIndex": 0, + "type": "LISTEN_TYPE", + "promptEn": "Type the letter you hear (long a).", + "answer": "á", + "acceptable": ["á", "a"], + "audioKey": "a-dlhe", + "hint": "Dĺžeň makes a vowel long.", + "register": "neutral" + }, + { + "id": "e0-abc-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "Which letter has a mäkčeň (softening mark)?", + "answer": "š", + "acceptable": ["š"], + "choices": ["š", "a", "o", "e"], + "hint": "The little v-shape on top.", + "register": "neutral" + }, + { + "id": "e0-abc-3", + "orderIndex": 2, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the sound: is it short or long?", + "answer": "í", + "acceptable": ["í"], + "choices": ["í", "i", "y", "ý"], + "audioKey": "i-dlhe", + "register": "neutral" + }, + { + "id": "e0-abc-4", + "orderIndex": 3, + "type": "MCQ", + "promptEn": "Which mark is the dĺžeň?", + "answer": "the line: á", + "acceptable": ["the line: á", "á"], + "choices": ["the line: á", "the hook: š", "the two dots: ä", "no mark: a"], + "register": "neutral" + }, + { + "id": "e0-abc-5", + "orderIndex": 4, + "type": "LISTEN_TYPE", + "promptEn": "Type the sound you hear: the 'ch' digraph.", + "answer": "ch", + "acceptable": ["ch"], + "audioKey": "ch", + "hint": "Like clearing your throat softly.", + "register": "neutral" + }, + { + "id": "e0-abc-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say the vowels: a á e é i í o ó u ú.", + "promptSk": "a á e é i í o ó u ú", + "answer": "a á e é i í o ó u ú", + "acceptable": ["a á e é i í o ó u ú"], + "audioKey": "vokaly", + "register": "neutral" + } + ] + }, + { + "id": "l0-abeceda-practice", + "title": "Krátke verzus dlhé", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e0-abp-1", + "orderIndex": 0, + "type": "LISTEN_CHOOSE", + "promptEn": "Which word do you hear? (sud = barrel / súd = court)", + "answer": "súd", + "acceptable": ["súd"], + "choices": ["súd", "sud"], + "audioKey": "sud-sud", + "hint": "Listen for the long ú.", + "register": "neutral" + }, + { + "id": "e0-abp-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Which word do you hear? (pas = passport / pás = belt)", + "answer": "pás", + "acceptable": ["pás"], + "choices": ["pás", "pas"], + "audioKey": "pas-pas", + "register": "neutral" + }, + { + "id": "e0-abp-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Which has a long vowel?", + "answer": "mám", + "acceptable": ["mám"], + "choices": ["mám", "mam", "man", "mat"], + "register": "neutral" + }, + { + "id": "e0-abp-4", + "orderIndex": 3, + "type": "LISTEN_TYPE", + "promptEn": "Type what you hear (a short word).", + "answer": "dom", + "acceptable": ["dom"], + "audioKey": "dom", + "hint": "It means 'house'.", + "register": "neutral" + }, + { + "id": "e0-abp-5", + "orderIndex": 4, + "type": "LISTEN_TYPE", + "promptEn": "Type what you hear (with a long vowel).", + "answer": "káva", + "acceptable": ["káva"], + "audioKey": "kava", + "hint": "It means 'coffee'.", + "register": "neutral" + }, + { + "id": "e0-abp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say both: 'sud' (short) then 'súd' (long).", + "promptSk": "sud, súd", + "answer": "sud súd", + "acceptable": ["sud súd", "sud, súd"], + "audioKey": "sud-sud", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s0-tvrde-zvuky", + "unitId": "u0", + "title": "Ťažké zvuky: ä ô ľ ď ť ň", + "description": "The tricky Slovak sounds: ä, the diphthong ô, and the soft consonants ľ ď ť ň.", + "iconKey": "sound", + "colorKey": "purple", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [], + "lessons": [ + { + "id": "l0-tvrde-teach", + "title": "Mäkké spoluhlásky", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e0-tt-1", + "orderIndex": 0, + "type": "LISTEN_TYPE", + "promptEn": "Type the soft sound you hear (ľ).", + "answer": "ľ", + "acceptable": ["ľ", "l"], + "audioKey": "lmakke", + "hint": "Tongue presses the palate: 'ly'.", + "register": "neutral" + }, + { + "id": "e0-tt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "Which is the soft 'n'?", + "answer": "ň", + "acceptable": ["ň"], + "choices": ["ň", "n", "m", "ñ"], + "register": "neutral" + }, + { + "id": "e0-tt-3", + "orderIndex": 2, + "type": "LISTEN_CHOOSE", + "promptEn": "Soft or hard? Choose what you hear.", + "answer": "ď", + "acceptable": ["ď"], + "choices": ["ď", "d", "t", "ť"], + "audioKey": "dmakke", + "hint": "Like 'dy'.", + "register": "neutral" + }, + { + "id": "e0-tt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Which sound do you hear?", + "answer": "ť", + "acceptable": ["ť"], + "choices": ["ť", "t", "č", "ć"], + "audioKey": "tmakke", + "register": "neutral" + }, + { + "id": "e0-tt-5", + "orderIndex": 4, + "type": "SPEAK", + "promptEn": "Say the soft consonants: ľ ď ť ň.", + "promptSk": "ľ ď ť ň", + "answer": "ľ ď ť ň", + "acceptable": ["ľ ď ť ň"], + "audioKey": "makke-spoluhlasky", + "register": "neutral" + }, + { + "id": "e0-tt-6", + "orderIndex": 5, + "type": "LISTEN_TYPE", + "promptEn": "Type the word you hear (deň = day).", + "answer": "deň", + "acceptable": ["deň"], + "audioKey": "den", + "register": "neutral" + } + ] + }, + { + "id": "l0-tvrde-practice", + "title": "ä, ô a prízvuk", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e0-tp-1", + "orderIndex": 0, + "type": "LISTEN_TYPE", + "promptEn": "Type the word you hear (mäso = meat).", + "answer": "mäso", + "acceptable": ["mäso"], + "audioKey": "maso", + "hint": "ä in modern speech sounds like 'e'.", + "register": "neutral" + }, + { + "id": "e0-tp-2", + "orderIndex": 1, + "type": "LISTEN_TYPE", + "promptEn": "Type the word you hear (stôl = table).", + "answer": "stôl", + "acceptable": ["stôl"], + "audioKey": "stol", + "hint": "ô sounds like 'uo'.", + "register": "neutral" + }, + { + "id": "e0-tp-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "How does the diphthong ô sound?", + "answer": "uo", + "acceptable": ["uo"], + "choices": ["uo", "oo", "ow", "aw"], + "register": "neutral" + }, + { + "id": "e0-tp-4", + "orderIndex": 3, + "type": "MCQ", + "promptEn": "In Slovak, which syllable is stressed?", + "answer": "the first syllable", + "acceptable": ["the first syllable", "first"], + "choices": ["the first syllable", "the last syllable", "the middle syllable", "it varies"], + "hint": "Always the first.", + "register": "neutral" + }, + { + "id": "e0-tp-5", + "orderIndex": 4, + "type": "LISTEN_CHOOSE", + "promptEn": "Where is the stress in 'ahoj'?", + "answer": "A-hoj", + "acceptable": ["a-hoj", "first"], + "choices": ["A-hoj", "a-HOJ"], + "audioKey": "ahoj", + "register": "neutral" + }, + { + "id": "e0-tp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say 'kôň' (horse) with the uo sound.", + "promptSk": "kôň", + "answer": "kôň", + "acceptable": ["kôň"], + "audioKey": "kon", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit1-ahoj.json b/slovko/app/src/main/assets/curriculum/unit1-ahoj.json new file mode 100644 index 0000000..3e16ccd --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit1-ahoj.json @@ -0,0 +1,356 @@ +{ + "unit": { "id": "u1", "name": "Ahoj", "cefr": "A1", "order": 1 }, + "skills": [ + { + "id": "s1-pozdravy", + "unitId": "u1", + "title": "Pozdravy", + "description": "Greetings: informal ahoj/čau versus formal dobrý deň, plus how are you.", + "iconKey": "wave", + "colorKey": "green", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v1-ahoj", "sk": "ahoj", "en": "hi / bye", "partOfSpeech": "interjection", "exampleSk": "Ahoj, ako sa máš?", "exampleEn": "Hi, how are you?", "register": "informal", "frequencyRank": 20 }, + { "id": "v1-cau", "sk": "čau", "en": "hi / bye (casual)", "partOfSpeech": "interjection", "exampleSk": "Čau, vidíme sa zajtra.", "exampleEn": "Bye, see you tomorrow.", "register": "informal", "frequencyRank": 60 }, + { "id": "v1-dobry-den", "sk": "dobrý deň", "en": "good day / hello", "partOfSpeech": "phrase", "exampleSk": "Dobrý deň, pán Novák.", "exampleEn": "Hello, Mr. Novák.", "register": "formal", "frequencyRank": 50 }, + { "id": "v1-dovidenia", "sk": "dovidenia", "en": "goodbye", "partOfSpeech": "interjection", "exampleSk": "Dovidenia, pekný deň.", "exampleEn": "Goodbye, have a nice day.", "register": "formal", "frequencyRank": 120 }, + { "id": "v1-ako-sa-mas", "sk": "ako sa máš", "en": "how are you (informal)", "partOfSpeech": "phrase", "exampleSk": "Ahoj, ako sa máš?", "exampleEn": "Hi, how are you?", "register": "informal", "frequencyRank": 90 }, + { "id": "v1-mam-sa-dobre", "sk": "mám sa dobre", "en": "I'm doing well", "partOfSpeech": "phrase", "exampleSk": "Mám sa dobre, ďakujem.", "exampleEn": "I'm doing well, thanks.", "register": "neutral", "frequencyRank": 130 } + ], + "lessons": [ + { + "id": "l1-pozdravy-teach", + "title": "Ahoj a dobrý deň", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e1-pt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'ahoj' mean?", + "answer": "hi / bye", + "acceptable": ["hi / bye", "hi", "hello"], + "choices": ["hi / bye", "thank you", "sorry", "please"], + "vocabCardId": "v1-ahoj", + "audioKey": "ahoj", + "register": "informal" + }, + { + "id": "e1-pt-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Which formal greeting do you hear?", + "answer": "dobrý deň", + "acceptable": ["dobrý deň"], + "choices": ["dobrý deň", "ahoj", "čau", "dovidenia"], + "vocabCardId": "v1-dobry-den", + "audioKey": "dobry-den", + "register": "formal" + }, + { + "id": "e1-pt-3", + "orderIndex": 2, + "type": "MATCH_PAIRS", + "promptEn": "Match the greetings.", + "answer": "", + "acceptable": [], + "pairs": [ + ["ahoj", "hi"], + ["dobrý deň", "good day"], + ["dovidenia", "goodbye"], + ["čau", "bye (casual)"] + ], + "register": "neutral" + }, + { + "id": "e1-pt-4", + "orderIndex": 3, + "type": "TRANSLATE_SK_EN", + "promptSk": "Ako sa máš?", + "answer": "How are you?", + "acceptable": ["how are you", "how are you?"], + "vocabCardId": "v1-ako-sa-mas", + "audioKey": "ako-sa-mas", + "register": "informal" + }, + { + "id": "e1-pt-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "You meet your boss in the morning. What do you say?", + "answer": "dobrý deň", + "acceptable": ["dobrý deň"], + "choices": ["dobrý deň", "čau", "ahoj", "no"], + "hint": "Formal situation.", + "register": "formal" + }, + { + "id": "e1-pt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm doing well, thanks.", + "answer": "Mám sa dobre, ďakujem.", + "acceptable": ["mám sa dobre, ďakujem", "mám sa dobre ďakujem", "mám sa dobre"], + "vocabCardId": "v1-mam-sa-dobre", + "audioKey": "mam-sa-dobre-dakujem", + "register": "neutral" + } + ] + }, + { + "id": "l1-pozdravy-practice", + "title": "Ako sa máš?", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e1-pp-1", + "orderIndex": 0, + "type": "LISTEN_CHOOSE", + "promptEn": "Which casual goodbye do you hear?", + "answer": "čau", + "acceptable": ["čau"], + "choices": ["čau", "dobrý deň", "prosím", "prepáč"], + "vocabCardId": "v1-cau", + "audioKey": "cau", + "register": "informal" + }, + { + "id": "e1-pp-2", + "orderIndex": 1, + "type": "TRANSLATE_SK_EN", + "promptSk": "Mám sa dobre.", + "answer": "I'm doing well.", + "acceptable": ["i'm doing well", "i am doing well", "i'm fine", "i'm good"], + "vocabCardId": "v1-mam-sa-dobre", + "register": "neutral" + }, + { + "id": "e1-pp-3", + "orderIndex": 2, + "type": "WORD_BANK", + "promptEn": "Build: 'Hi, how are you?'", + "answer": "Ahoj, ako sa máš?", + "acceptable": ["ahoj, ako sa máš?", "ahoj ako sa máš"], + "choices": ["ako", "Ahoj,", "máš?", "sa"], + "register": "informal" + }, + { + "id": "e1-pp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "Goodbye, have a nice day.", + "answer": "Dovidenia, pekný deň.", + "acceptable": ["dovidenia, pekný deň", "dovidenia pekný deň", "dovidenia"], + "vocabCardId": "v1-dovidenia", + "audioKey": "dovidenia-pekny-den", + "register": "formal" + }, + { + "id": "e1-pp-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "A friend says 'Ako sa máš?'. A natural reply is:", + "answer": "Dobre, a ty?", + "acceptable": ["dobre, a ty?", "dobre a ty"], + "choices": ["Dobre, a ty?", "Dovidenia.", "Prepáč.", "Nech sa páči."], + "register": "informal" + }, + { + "id": "e1-pp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'Hi, how are you?'", + "promptSk": "Ahoj, ako sa máš?", + "answer": "Ahoj, ako sa máš?", + "acceptable": ["ahoj, ako sa máš?", "ahoj ako sa máš"], + "audioKey": "ahoj-ako-sa-mas", + "register": "informal" + } + ] + } + ] + }, + { + "id": "s1-zdvorilost", + "unitId": "u1", + "title": "Volám sa a zdvorilosť", + "description": "Introducing yourself, please/thank you/sorry, texting openers, and tykať vs vykať.", + "iconKey": "chat", + "colorKey": "orange", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v1-volam-sa", "sk": "volám sa", "en": "my name is", "partOfSpeech": "phrase", "exampleSk": "Volám sa Petra.", "exampleEn": "My name is Petra.", "register": "neutral", "frequencyRank": 110 }, + { "id": "v1-dakujem", "sk": "ďakujem", "en": "thank you", "partOfSpeech": "interjection", "exampleSk": "Ďakujem za pomoc.", "exampleEn": "Thanks for the help.", "register": "neutral", "frequencyRank": 70 }, + { "id": "v1-prosim", "sk": "prosím", "en": "please / you're welcome", "partOfSpeech": "interjection", "exampleSk": "Kávu, prosím.", "exampleEn": "A coffee, please.", "register": "neutral", "frequencyRank": 80 }, + { "id": "v1-prepac", "sk": "prepáč", "en": "sorry (informal)", "partOfSpeech": "interjection", "exampleSk": "Prepáč, meškám.", "exampleEn": "Sorry, I'm late.", "register": "informal", "frequencyRank": 140 }, + { "id": "v1-prepacte", "sk": "prepáčte", "en": "excuse me / sorry (formal)", "partOfSpeech": "interjection", "exampleSk": "Prepáčte, kde je stanica?", "exampleEn": "Excuse me, where is the station?", "register": "formal", "frequencyRank": 150 }, + { "id": "v1-tesi-ma", "sk": "teší ma", "en": "nice to meet you", "partOfSpeech": "phrase", "exampleSk": "Teší ma, ja som Ján.", "exampleEn": "Nice to meet you, I'm Ján.", "register": "neutral", "frequencyRank": 200 } + ], + "lessons": [ + { + "id": "l1-zdvor-teach", + "title": "Volám sa...", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e1-zt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "How do you say 'my name is'?", + "answer": "volám sa", + "acceptable": ["volám sa"], + "choices": ["volám sa", "mám sa", "teší ma", "prosím"], + "vocabCardId": "v1-volam-sa", + "audioKey": "volam-sa", + "register": "neutral" + }, + { + "id": "e1-zt-2", + "orderIndex": 1, + "type": "MATCH_PAIRS", + "promptEn": "Match the polite words.", + "answer": "", + "acceptable": [], + "pairs": [ + ["ďakujem", "thank you"], + ["prosím", "please"], + ["prepáč", "sorry"], + ["teší ma", "nice to meet you"] + ], + "register": "neutral" + }, + { + "id": "e1-zt-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Teší ma.", + "answer": "Nice to meet you.", + "acceptable": ["nice to meet you", "pleased to meet you"], + "vocabCardId": "v1-tesi-ma", + "register": "neutral" + }, + { + "id": "e1-zt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Which word means 'thank you'?", + "answer": "ďakujem", + "acceptable": ["ďakujem"], + "choices": ["ďakujem", "prosím", "prepáč", "ahoj"], + "vocabCardId": "v1-dakujem", + "audioKey": "dakujem", + "register": "neutral" + }, + { + "id": "e1-zt-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'My name is Petra.'", + "answer": "Volám sa Petra.", + "acceptable": ["volám sa petra", "volám sa petra."], + "choices": ["Petra", "Volám", "sa"], + "register": "neutral" + }, + { + "id": "e1-zt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "Thanks for the help.", + "answer": "Ďakujem za pomoc.", + "acceptable": ["ďakujem za pomoc", "ďakujem za pomoc."], + "vocabCardId": "v1-dakujem", + "audioKey": "dakujem-za-pomoc", + "register": "neutral" + } + ] + }, + { + "id": "l1-zdvor-practice", + "title": "Tykať alebo vykať?", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e1-zp-1", + "orderIndex": 0, + "type": "DIALOGUE_FILL", + "promptEn": "At a shop, the clerk asks formally. Fill: 'Dobrý deň, ___ pomôcť?' (can I help you - formal)", + "promptSk": "Dobrý deň, ___ vám pomôcť?", + "answer": "môžem", + "acceptable": ["môžem"], + "choices": ["môžem", "môžeš", "máš", "si"], + "hint": "Formal vykanie uses 'vám'.", + "register": "formal" + }, + { + "id": "e1-zp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "Talking to a friend, fill the informal question: 'Ahoj, ako ___ máš?'", + "promptSk": "Ahoj, ako ___ máš?", + "answer": "sa", + "acceptable": ["sa"], + "choices": ["sa", "si", "ste", "vám"], + "hint": "Informal tykanie.", + "register": "informal" + }, + { + "id": "e1-zp-3", + "orderIndex": 2, + "type": "DIALOGUE_FILL", + "promptEn": "Formal: 'Ako sa ___?' (How are you, to a stranger)", + "promptSk": "Ako sa ___?", + "answer": "máte", + "acceptable": ["máte"], + "choices": ["máte", "máš", "mám", "majú"], + "hint": "Vykanie uses the plural verb form.", + "register": "formal" + }, + { + "id": "e1-zp-4", + "orderIndex": 3, + "type": "MCQ", + "promptEn": "Which is the formal 'sorry / excuse me'?", + "answer": "prepáčte", + "acceptable": ["prepáčte"], + "choices": ["prepáčte", "prepáč", "prosím", "ďakujem"], + "vocabCardId": "v1-prepacte", + "audioKey": "prepacte", + "register": "formal" + }, + { + "id": "e1-zp-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build (texting a friend): 'Hi, sorry, I'm late.'", + "answer": "Čau, prepáč, meškám.", + "acceptable": ["čau, prepáč, meškám", "čau prepáč meškám"], + "choices": ["meškám.", "Čau,", "prepáč,"], + "register": "informal" + }, + { + "id": "e1-zp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'Nice to meet you, I'm Ján.'", + "promptSk": "Teší ma, ja som Ján.", + "answer": "Teší ma, ja som Ján.", + "acceptable": ["teší ma, ja som ján", "teší ma ja som ján"], + "audioKey": "tesi-ma-ja-som-jan", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit10-praca-skola.json b/slovko/app/src/main/assets/curriculum/unit10-praca-skola.json new file mode 100644 index 0000000..2eadf01 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit10-praca-skola.json @@ -0,0 +1,382 @@ +{ + "unit": { "id": "u10", "name": "Práca a škola", "cefr": "A2", "order": 10 }, + "skills": [ + { + "id": "s10-co-robis", + "unitId": "u10", + "title": "Čím si?", + "description": "Talking about your job and studies: čím si, kde robíš, čo študuješ, and the instrumental case of professions (som učiteľom) via FILL_CASE.", + "iconKey": "work", + "colorKey": "blue", + "orderIndex": 0, + "cefrLevel": "A2", + "vocab": [ + { "id": "v10-praca", "sk": "práca", "en": "work / job", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Idem do práce.", "exampleEn": "I'm going to work.", "register": "neutral", "frequencyRank": 110 }, + { "id": "v10-robit", "sk": "robiť", "en": "to work / do", "partOfSpeech": "verb", "exampleSk": "Kde robíš?", "exampleEn": "Where do you work?", "register": "informal", "frequencyRank": 90 }, + { "id": "v10-ucitel", "sk": "učiteľ", "en": "teacher", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Som učiteľom.", "exampleEn": "I'm a teacher.", "register": "neutral", "frequencyRank": 540 }, + { "id": "v10-lekar", "sk": "lekár", "en": "doctor", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Pracuje ako lekár.", "exampleEn": "He works as a doctor.", "register": "neutral", "frequencyRank": 550 }, + { "id": "v10-student", "sk": "študent", "en": "student", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Som ešte študent.", "exampleEn": "I'm still a student.", "register": "neutral", "frequencyRank": 470 }, + { "id": "v10-studovat", "sk": "študovať", "en": "to study", "partOfSpeech": "verb", "exampleSk": "Čo študuješ?", "exampleEn": "What do you study?", "register": "neutral", "frequencyRank": 430 }, + { "id": "v10-firma", "sk": "firma", "en": "company / firm", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Robím vo veľkej firme.", "exampleEn": "I work at a big company.", "register": "neutral", "frequencyRank": 600 }, + { "id": "v10-programator", "sk": "programátor", "en": "programmer", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Som programátorom.", "exampleEn": "I'm a programmer.", "register": "neutral", "frequencyRank": 720 } + ], + "lessons": [ + { + "id": "l10-cr-teach", + "title": "Kde robíš?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e10-crt-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Kde robíš?", + "answer": "Where do you work?", + "acceptable": ["where do you work?", "where do you work"], + "vocabCardId": "v10-robit", + "register": "informal" + }, + { + "id": "e10-crt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "How do you ask 'What do you study?' (informal)?", + "answer": "Čo študuješ?", + "acceptable": ["čo študuješ?"], + "choices": ["Čo študuješ?", "Čo študujem?", "Kde študuješ?", "Čo robíš?"], + "vocabCardId": "v10-studovat", + "register": "informal" + }, + { + "id": "e10-crt-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Which word means 'company / firm'?", + "answer": "firma", + "acceptable": ["firma"], + "choices": ["firma", "práca", "škola", "mesto"], + "vocabCardId": "v10-firma", + "register": "neutral" + }, + { + "id": "e10-crt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: what is the person's job?", + "answer": "Som lekár.", + "acceptable": ["som lekár."], + "choices": ["Som lekár.", "Som učiteľ.", "Som študent.", "Som programátor."], + "audioKey": "som-lekar", + "vocabCardId": "v10-lekar", + "register": "neutral" + }, + { + "id": "e10-crt-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the profession to its meaning.", + "answer": "", + "pairs": [["učiteľ", "teacher"], ["lekár", "doctor"], ["študent", "student"], ["programátor", "programmer"], ["práca", "work"]], + "register": "neutral" + }, + { + "id": "e10-crt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm going to work.", + "answer": "Idem do práce.", + "acceptable": ["idem do práce.", "idem do práce"], + "vocabCardId": "v10-praca", + "register": "neutral" + } + ] + }, + { + "id": "l10-cr-practice", + "title": "Čím si? (inštrumentál)", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e10-crp-1", + "orderIndex": 0, + "type": "FILL_CASE", + "promptEn": "Say your profession: 'I'm a teacher.' Use the instrumental.", + "promptSk": "Som ____. (učiteľ)", + "answer": "učiteľom", + "acceptable": ["učiteľom"], + "choices": ["učiteľom", "učiteľ", "učiteľa", "učiteľovi"], + "hint": "Profession with 'byť' takes the instrumental: -om for masculine.", + "vocabCardId": "v10-ucitel", + "register": "neutral", + "caseTag": "INS" + }, + { + "id": "e10-crp-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "'I'm a programmer.' Put the profession in the instrumental.", + "promptSk": "Som ____. (programátor)", + "answer": "programátorom", + "acceptable": ["programátorom"], + "choices": ["programátorom", "programátor", "programátora", "programátoru"], + "hint": "Masculine instrumental ends in -om.", + "vocabCardId": "v10-programator", + "register": "neutral", + "caseTag": "INS" + }, + { + "id": "e10-crp-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "'She's a doctor.' Choose the instrumental of 'lekárka'.", + "promptSk": "Je ____. (lekárka)", + "answer": "lekárkou", + "acceptable": ["lekárkou"], + "choices": ["lekárkou", "lekárka", "lekárku", "lekárke"], + "hint": "Feminine instrumental ends in -ou.", + "vocabCardId": "v10-lekar", + "register": "neutral", + "caseTag": "INS" + }, + { + "id": "e10-crp-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build: 'I work at a big company.'", + "answer": "Robím vo veľkej firme.", + "acceptable": ["robím vo veľkej firme.", "robím vo veľkej firme"], + "choices": ["Robím", "vo", "veľkej", "firme.", "do"], + "vocabCardId": "v10-firma", + "register": "informal" + }, + { + "id": "e10-crp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm still a student.", + "answer": "Som ešte študent.", + "acceptable": ["som ešte študent.", "som ešte študent"], + "vocabCardId": "v10-student", + "register": "neutral" + }, + { + "id": "e10-crp-6", + "orderIndex": 5, + "type": "DIALOGUE_FILL", + "promptEn": "— Čím si? — Som ____. (I'm a teacher)", + "promptSk": "— Čím si? — Som ____.", + "answer": "učiteľom", + "acceptable": ["učiteľom"], + "choices": ["učiteľom", "učiteľ", "učiteľa", "učiteľmi"], + "hint": "'Čím si?' literally asks 'what are you (instr.)?'", + "vocabCardId": "v10-ucitel", + "register": "informal", + "caseTag": "INS" + }, + { + "id": "e10-crp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say: 'What do you study?'", + "promptSk": "Čo študuješ?", + "answer": "Čo študuješ?", + "acceptable": ["čo študuješ?", "čo študuješ"], + "audioKey": "co-studujes", + "vocabCardId": "v10-studovat", + "register": "informal" + } + ] + } + ] + }, + { + "id": "s10-v-praci", + "unitId": "u10", + "title": "V práci a v škole", + "description": "Workplace and school vocabulary: meetings, the office, colleagues, the timetable, plus everyday phrases like mám veľa roboty.", + "iconKey": "person", + "colorKey": "orange", + "orderIndex": 1, + "cefrLevel": "A2", + "vocab": [ + { "id": "v10-kolega", "sk": "kolega", "en": "colleague", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Môj kolega je fajn.", "exampleEn": "My colleague is nice.", "register": "neutral", "frequencyRank": 650 }, + { "id": "v10-sef", "sk": "šéf", "en": "boss", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Šéf má dnes dobrú náladu.", "exampleEn": "The boss is in a good mood today.", "register": "informal", "frequencyRank": 680 }, + { "id": "v10-stretnutie", "sk": "stretnutie", "en": "meeting", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Mám stretnutie o desať.", "exampleEn": "I have a meeting at ten.", "register": "neutral", "frequencyRank": 590 }, + { "id": "v10-kancelaria", "sk": "kancelária", "en": "office", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Sedím v kancelárii.", "exampleEn": "I sit in the office.", "register": "neutral", "frequencyRank": 700 }, + { "id": "v10-skola", "sk": "škola", "en": "school", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Idem do školy.", "exampleEn": "I'm going to school.", "register": "neutral", "frequencyRank": 200 }, + { "id": "v10-skuska", "sk": "skúška", "en": "exam", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Zajtra mám skúšku.", "exampleEn": "I have an exam tomorrow.", "register": "neutral", "frequencyRank": 660 }, + { "id": "v10-robota", "sk": "robota", "en": "work / loads to do", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Mám veľa roboty.", "exampleEn": "I have a lot of work.", "register": "informal", "frequencyRank": 480 }, + { "id": "v10-unaveny", "sk": "unavený", "en": "tired", "partOfSpeech": "adjective", "exampleSk": "Som unavený z práce.", "exampleEn": "I'm tired from work.", "register": "neutral", "frequencyRank": 520 } + ], + "lessons": [ + { + "id": "l10-vp-teach", + "title": "Kolegovia a šéf", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e10-vpt-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Mám veľa roboty.", + "answer": "I have a lot of work.", + "acceptable": ["i have a lot of work.", "i have a lot of work", "i have lots of work"], + "vocabCardId": "v10-robota", + "register": "informal" + }, + { + "id": "e10-vpt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "Which word means 'colleague'?", + "answer": "kolega", + "acceptable": ["kolega"], + "choices": ["kolega", "šéf", "lekár", "sused"], + "vocabCardId": "v10-kolega", + "register": "neutral" + }, + { + "id": "e10-vpt-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "'I have a meeting at ten.' is...", + "answer": "Mám stretnutie o desať.", + "acceptable": ["mám stretnutie o desať."], + "choices": ["Mám stretnutie o desať.", "Mám skúšku o desať.", "Idem do práce o desať.", "Som v kancelárii o desať."], + "vocabCardId": "v10-stretnutie", + "register": "neutral" + }, + { + "id": "e10-vpt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: where is the person?", + "answer": "Sedím v kancelárii.", + "acceptable": ["sedím v kancelárii."], + "choices": ["Sedím v kancelárii.", "Som v škole.", "Idem domov.", "Som na obede."], + "audioKey": "sedim-v-kancelarii", + "vocabCardId": "v10-kancelaria", + "register": "neutral" + }, + { + "id": "e10-vpt-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the workplace word to its meaning.", + "answer": "", + "pairs": [["šéf", "boss"], ["kancelária", "office"], ["stretnutie", "meeting"], ["skúška", "exam"], ["škola", "school"]], + "register": "neutral" + }, + { + "id": "e10-vpt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I have an exam tomorrow.", + "answer": "Zajtra mám skúšku.", + "acceptable": ["zajtra mám skúšku.", "zajtra mám skúšku", "mám zajtra skúšku."], + "vocabCardId": "v10-skuska", + "register": "neutral" + } + ] + }, + { + "id": "l10-vp-practice", + "title": "Deň v práci", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e10-vpp-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "Which sentence means 'I'm tired from work.'?", + "answer": "Som unavený z práce.", + "acceptable": ["som unavený z práce."], + "choices": ["Som unavený z práce.", "Som unavený zo školy.", "Idem do práce.", "Mám veľa roboty."], + "vocabCardId": "v10-unaveny", + "register": "neutral" + }, + { + "id": "e10-vpp-2", + "orderIndex": 1, + "type": "WORD_BANK", + "promptEn": "Build: 'I have a meeting at ten.'", + "answer": "Mám stretnutie o desať.", + "acceptable": ["mám stretnutie o desať.", "mám stretnutie o desať"], + "choices": ["Mám", "stretnutie", "o", "desať.", "skúšku"], + "vocabCardId": "v10-stretnutie", + "register": "neutral" + }, + { + "id": "e10-vpp-3", + "orderIndex": 2, + "type": "DIALOGUE_FILL", + "promptEn": "— Ako bolo v práci? — Náročné, mám veľa ____.", + "promptSk": "— Ako bolo v práci? — Náročné, mám veľa ____.", + "answer": "roboty", + "acceptable": ["roboty"], + "choices": ["roboty", "robota", "robotu", "robote"], + "hint": "'veľa' (a lot of) takes the genitive: roboty.", + "vocabCardId": "v10-robota", + "register": "informal", + "caseTag": "GEN" + }, + { + "id": "e10-vpp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "My colleague is nice.", + "answer": "Môj kolega je fajn.", + "acceptable": ["môj kolega je fajn.", "môj kolega je fajn"], + "vocabCardId": "v10-kolega", + "register": "informal" + }, + { + "id": "e10-vpp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm going to school.", + "answer": "Idem do školy.", + "acceptable": ["idem do školy.", "idem do školy"], + "vocabCardId": "v10-skola", + "register": "neutral" + }, + { + "id": "e10-vpp-6", + "orderIndex": 5, + "type": "DIALOGUE_FILL", + "promptEn": "— Kde si? — V ____, mám stretnutie. (office)", + "promptSk": "— Kde si? — V ____, mám stretnutie.", + "answer": "kancelárii", + "acceptable": ["kancelárii"], + "choices": ["kancelárii", "kancelária", "kanceláriu", "kanceláriou"], + "hint": "'v' + locative: kancelárii.", + "vocabCardId": "v10-kancelaria", + "register": "informal", + "caseTag": "LOC" + }, + { + "id": "e10-vpp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say: 'I'm tired from work.'", + "promptSk": "Som unavený z práce.", + "answer": "Som unavený z práce.", + "acceptable": ["som unavený z práce.", "som unavený z práce"], + "audioKey": "som-unaveny-z-prace", + "vocabCardId": "v10-unaveny", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit11-cestovanie.json b/slovko/app/src/main/assets/curriculum/unit11-cestovanie.json new file mode 100644 index 0000000..090c60e --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit11-cestovanie.json @@ -0,0 +1,361 @@ +{ + "unit": { "id": "u11", "name": "Cestovanie", "cefr": "A2", "order": 11 }, + "skills": [ + { + "id": "s11-doprava", + "unitId": "u11", + "title": "Vlak a autobus", + "description": "Trains and buses, buying tickets and booking: lístok, nástupište, kedy ide, koľko stojí. Travel verbs and platform vocabulary.", + "iconKey": "travel", + "colorKey": "blue", + "orderIndex": 0, + "cefrLevel": "A2", + "vocab": [ + { "id": "v11-vlak", "sk": "vlak", "en": "train", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Vlak ide o piatej.", "exampleEn": "The train leaves at five.", "register": "neutral", "frequencyRank": 420 }, + { "id": "v11-autobus", "sk": "autobus", "en": "bus", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Autobus meška.", "exampleEn": "The bus is late.", "register": "neutral", "frequencyRank": 440 }, + { "id": "v11-listok", "sk": "lístok", "en": "ticket", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Jeden lístok, prosím.", "exampleEn": "One ticket, please.", "register": "neutral", "frequencyRank": 560 }, + { "id": "v11-nastupiste", "sk": "nástupište", "en": "platform", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Vlak ide z nástupišťa tri.", "exampleEn": "The train leaves from platform three.", "register": "neutral", "frequencyRank": 740 }, + { "id": "v11-stanica", "sk": "stanica", "en": "station", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Stretneme sa na stanici.", "exampleEn": "We'll meet at the station.", "register": "neutral", "frequencyRank": 500 }, + { "id": "v11-rezervovat", "sk": "rezervovať", "en": "to book / reserve", "partOfSpeech": "verb", "exampleSk": "Chcem si rezervovať miesto.", "exampleEn": "I want to book a seat.", "register": "neutral", "frequencyRank": 690 }, + { "id": "v11-odchadza", "sk": "odchádzať", "en": "to depart / leave", "partOfSpeech": "verb", "exampleSk": "Kedy odchádza vlak?", "exampleEn": "When does the train leave?", "register": "neutral", "frequencyRank": 710 }, + { "id": "v11-mesto-cieľ", "sk": "Bratislava", "en": "Bratislava", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Idem do Bratislavy.", "exampleEn": "I'm going to Bratislava.", "register": "neutral", "frequencyRank": 300 } + ], + "lessons": [ + { + "id": "l11-dop-teach", + "title": "Na stanici", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e11-dt-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Kedy odchádza vlak?", + "answer": "When does the train leave?", + "acceptable": ["when does the train leave?", "when does the train leave", "when does the train depart?"], + "vocabCardId": "v11-odchadza", + "register": "neutral" + }, + { + "id": "e11-dt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "How do you say 'One ticket, please.'?", + "answer": "Jeden lístok, prosím.", + "acceptable": ["jeden lístok, prosím."], + "choices": ["Jeden lístok, prosím.", "Jeden vlak, prosím.", "Jedna stanica, prosím.", "Jeden autobus, prosím."], + "vocabCardId": "v11-listok", + "register": "neutral" + }, + { + "id": "e11-dt-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Which word means 'platform'?", + "answer": "nástupište", + "acceptable": ["nástupište"], + "choices": ["nástupište", "stanica", "lístok", "autobus"], + "vocabCardId": "v11-nastupiste", + "register": "neutral" + }, + { + "id": "e11-dt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: what's the problem?", + "answer": "Autobus meška.", + "acceptable": ["autobus meška."], + "choices": ["Autobus meška.", "Vlak ide o piatej.", "Nemám lístok.", "Stanica je ďaleko."], + "audioKey": "autobus-meska", + "vocabCardId": "v11-autobus", + "register": "neutral" + }, + { + "id": "e11-dt-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the travel word to its meaning.", + "answer": "", + "pairs": [["vlak", "train"], ["autobus", "bus"], ["lístok", "ticket"], ["stanica", "station"], ["nástupište", "platform"]], + "register": "neutral" + }, + { + "id": "e11-dt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "We'll meet at the station.", + "answer": "Stretneme sa na stanici.", + "acceptable": ["stretneme sa na stanici.", "stretneme sa na stanici"], + "vocabCardId": "v11-stanica", + "register": "neutral" + } + ] + }, + { + "id": "l11-dop-practice", + "title": "Kúpa lístka", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e11-dp-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "Ask 'How much does a ticket cost?'", + "answer": "Koľko stojí lístok?", + "acceptable": ["koľko stojí lístok?"], + "choices": ["Koľko stojí lístok?", "Kedy ide lístok?", "Kde je lístok?", "Aký je lístok?"], + "vocabCardId": "v11-listok", + "register": "neutral" + }, + { + "id": "e11-dp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "— Dobrý deň, jeden lístok do Bratislavy. — Z ____ tri, prosím.", + "promptSk": "— Jeden lístok do Bratislavy. — Z ____ tri, prosím.", + "answer": "nástupišťa", + "acceptable": ["nástupišťa"], + "choices": ["nástupišťa", "nástupište", "nástupišťu", "nástupištia"], + "hint": "'z' (from) takes the genitive: nástupišťa.", + "vocabCardId": "v11-nastupiste", + "register": "formal", + "caseTag": "GEN" + }, + { + "id": "e11-dp-3", + "orderIndex": 2, + "type": "WORD_BANK", + "promptEn": "Build: 'I want to book a seat.'", + "answer": "Chcem si rezervovať miesto.", + "acceptable": ["chcem si rezervovať miesto.", "chcem si rezervovať miesto"], + "choices": ["Chcem", "si", "rezervovať", "miesto.", "lístok"], + "vocabCardId": "v11-rezervovat", + "register": "neutral" + }, + { + "id": "e11-dp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm going to Bratislava.", + "answer": "Idem do Bratislavy.", + "acceptable": ["idem do bratislavy.", "idem do bratislavy"], + "vocabCardId": "v11-mesto-cieľ", + "register": "neutral" + }, + { + "id": "e11-dp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "When does the bus leave?", + "answer": "Kedy odchádza autobus?", + "acceptable": ["kedy odchádza autobus?", "kedy odchádza autobus"], + "vocabCardId": "v11-odchadza", + "register": "neutral" + }, + { + "id": "e11-dp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'One ticket to Bratislava, please.'", + "promptSk": "Jeden lístok do Bratislavy, prosím.", + "answer": "Jeden lístok do Bratislavy, prosím.", + "acceptable": ["jeden lístok do bratislavy, prosím.", "jeden lístok do bratislavy, prosím"], + "audioKey": "jeden-listok-do-bratislavy-prosim", + "vocabCardId": "v11-listok", + "register": "formal" + } + ] + } + ] + }, + { + "id": "s11-ubytovanie", + "unitId": "u11", + "title": "Ubytovanie a pomoc", + "description": "Hotels and accommodation, asking for help and handling emergencies. Plus a first taste of the PAST tense (bol som, kúpil som) built with WORD_BANK.", + "iconKey": "city", + "colorKey": "green", + "orderIndex": 1, + "cefrLevel": "A2", + "vocab": [ + { "id": "v11-hotel", "sk": "hotel", "en": "hotel", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Máte voľnú izbu?", "exampleEn": "Do you have a free room?", "register": "neutral", "frequencyRank": 580 }, + { "id": "v11-izba", "sk": "izba", "en": "room", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Rezervoval som izbu.", "exampleEn": "I booked a room.", "register": "neutral", "frequencyRank": 410 }, + { "id": "v11-noc", "sk": "noc", "en": "night", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Na dve noci.", "exampleEn": "For two nights.", "register": "neutral", "frequencyRank": 380 }, + { "id": "v11-pomoc", "sk": "pomoc", "en": "help", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Potrebujem pomoc.", "exampleEn": "I need help.", "register": "neutral", "frequencyRank": 330 }, + { "id": "v11-stratit", "sk": "stratiť", "en": "to lose", "partOfSpeech": "verb", "exampleSk": "Stratil som pas.", "exampleEn": "I lost my passport.", "register": "neutral", "frequencyRank": 670 }, + { "id": "v11-policia", "sk": "polícia", "en": "police", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Zavolajte políciu!", "exampleEn": "Call the police!", "register": "neutral", "frequencyRank": 540 }, + { "id": "v11-byt-past", "sk": "bol som", "en": "I was", "partOfSpeech": "verb", "exampleSk": "Bol som v hoteli.", "exampleEn": "I was at the hotel.", "register": "neutral", "frequencyRank": 80 }, + { "id": "v11-kupit-past", "sk": "kúpil som", "en": "I bought", "partOfSpeech": "verb", "exampleSk": "Kúpil som lístok.", "exampleEn": "I bought a ticket.", "register": "neutral", "frequencyRank": 260 } + ], + "lessons": [ + { + "id": "l11-ub-teach", + "title": "V hoteli", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e11-ut-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Máte voľnú izbu?", + "answer": "Do you have a free room?", + "acceptable": ["do you have a free room?", "do you have a free room", "do you have a room available?"], + "vocabCardId": "v11-hotel", + "register": "formal" + }, + { + "id": "e11-ut-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "How do you say 'For two nights.'?", + "answer": "Na dve noci.", + "acceptable": ["na dve noci."], + "choices": ["Na dve noci.", "Na dva dni.", "Na dve izby.", "Dve noci sú."], + "vocabCardId": "v11-noc", + "register": "neutral" + }, + { + "id": "e11-ut-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Which word means 'help'?", + "answer": "pomoc", + "acceptable": ["pomoc"], + "choices": ["pomoc", "izba", "noc", "hotel"], + "vocabCardId": "v11-pomoc", + "register": "neutral" + }, + { + "id": "e11-ut-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: what does the person need?", + "answer": "Potrebujem pomoc.", + "acceptable": ["potrebujem pomoc."], + "choices": ["Potrebujem pomoc.", "Potrebujem izbu.", "Potrebujem lístok.", "Potrebujem taxík."], + "audioKey": "potrebujem-pomoc", + "vocabCardId": "v11-pomoc", + "register": "neutral" + }, + { + "id": "e11-ut-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the word to its meaning.", + "answer": "", + "pairs": [["hotel", "hotel"], ["izba", "room"], ["noc", "night"], ["pomoc", "help"], ["polícia", "police"]], + "register": "neutral" + }, + { + "id": "e11-ut-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "Call the police!", + "answer": "Zavolajte políciu!", + "acceptable": ["zavolajte políciu!", "zavolajte políciu", "zavolajte policiu!"], + "vocabCardId": "v11-policia", + "register": "formal" + } + ] + }, + { + "id": "l11-ub-practice", + "title": "Minulý čas: bol som, kúpil som", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e11-up-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "How do you say 'I was at the hotel.' (male speaker)?", + "answer": "Bol som v hoteli.", + "acceptable": ["bol som v hoteli."], + "choices": ["Bol som v hoteli.", "Som v hoteli.", "Budem v hoteli.", "Bola som v hoteli."], + "hint": "Past = past participle (bol) + present 'som'.", + "vocabCardId": "v11-byt-past", + "register": "neutral" + }, + { + "id": "e11-up-2", + "orderIndex": 1, + "type": "WORD_BANK", + "promptEn": "Build the past tense: 'I was at the hotel.' (male)", + "answer": "Bol som v hoteli.", + "acceptable": ["bol som v hoteli.", "bol som v hoteli"], + "choices": ["Bol", "som", "v", "hoteli.", "budem"], + "hint": "Word order: participle + som.", + "vocabCardId": "v11-byt-past", + "register": "neutral" + }, + { + "id": "e11-up-3", + "orderIndex": 2, + "type": "WORD_BANK", + "promptEn": "Build the past tense: 'I bought a ticket.' (male)", + "answer": "Kúpil som lístok.", + "acceptable": ["kúpil som lístok.", "kúpil som lístok"], + "choices": ["Kúpil", "som", "lístok.", "kupujem", "budem"], + "hint": "kúpil (bought) + som.", + "vocabCardId": "v11-kupit-past", + "register": "neutral" + }, + { + "id": "e11-up-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build the past tense: 'I lost my passport.' (male)", + "answer": "Stratil som pas.", + "acceptable": ["stratil som pas.", "stratil som pas"], + "choices": ["Stratil", "som", "pas.", "strácam", "stratím"], + "vocabCardId": "v11-stratit", + "register": "neutral" + }, + { + "id": "e11-up-5", + "orderIndex": 4, + "type": "DIALOGUE_FILL", + "promptEn": "— Čo sa stalo? — ____ som pas! (I lost)", + "promptSk": "— Čo sa stalo? — ____ som pas!", + "answer": "Stratil", + "acceptable": ["stratil"], + "choices": ["Stratil", "Strácam", "Stratím", "Stratiť"], + "hint": "Past participle, male speaker: stratil.", + "vocabCardId": "v11-stratit", + "register": "informal" + }, + { + "id": "e11-up-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I booked a room for two nights.", + "answer": "Rezervoval som izbu na dve noci.", + "acceptable": ["rezervoval som izbu na dve noci.", "rezervoval som izbu na dve noci"], + "vocabCardId": "v11-izba", + "register": "neutral" + }, + { + "id": "e11-up-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say (emergency): 'I lost my passport, I need help.'", + "promptSk": "Stratil som pas, potrebujem pomoc.", + "answer": "Stratil som pas, potrebujem pomoc.", + "acceptable": ["stratil som pas, potrebujem pomoc.", "stratil som pas, potrebujem pomoc"], + "audioKey": "stratil-som-pas-potrebujem-pomoc", + "vocabCardId": "v11-stratit", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit12-piseme-si.json b/slovko/app/src/main/assets/curriculum/unit12-piseme-si.json new file mode 100644 index 0000000..8c13a6f --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit12-piseme-si.json @@ -0,0 +1,387 @@ +{ + "unit": { "id": "u12", "name": "Píšeme si", "cefr": "A2", "order": 12 }, + "skills": [ + { + "id": "s12-chat-plany", + "unitId": "u12", + "title": "Dohodnime sa", + "description": "Real WhatsApp-style chats to make plans with friends: čo robíš, kde sa stretneme, o koľkej, dohodnuté. Pulls together time, plans and casual texting.", + "iconKey": "phone", + "colorKey": "teal", + "orderIndex": 0, + "cefrLevel": "A2", + "vocab": [ + { "id": "v12-cau", "sk": "čau", "en": "hi / bye (casual)", "partOfSpeech": "interjection", "exampleSk": "Čau, čo robíš?", "exampleEn": "Hey, what are you up to?", "register": "informal", "frequencyRank": 60 }, + { "id": "v12-co-nove", "sk": "čo nové", "en": "what's new / what's up", "partOfSpeech": "phrase", "exampleSk": "Ahoj, čo nové?", "exampleEn": "Hi, what's new?", "register": "informal", "frequencyRank": 220 }, + { "id": "v12-ides", "sk": "ideš?", "en": "are you coming / in?", "partOfSpeech": "verb", "exampleSk": "Ideme na pivo, ideš?", "exampleEn": "We're going for a beer, you in?", "register": "informal", "frequencyRank": 150 }, + { "id": "v12-dohodnute", "sk": "dohodnuté", "en": "deal / agreed", "partOfSpeech": "adjective", "exampleSk": "Super, dohodnuté!", "exampleEn": "Great, it's a deal!", "register": "informal", "frequencyRank": 700 }, + { "id": "v12-mozem", "sk": "môžem", "en": "I can", "partOfSpeech": "verb", "exampleSk": "Dnes nemôžem.", "exampleEn": "I can't today.", "register": "informal", "frequencyRank": 100 }, + { "id": "v12-napis", "sk": "napíš", "en": "text me / write", "partOfSpeech": "verb", "exampleSk": "Napíš mi neskôr.", "exampleEn": "Text me later.", "register": "informal", "frequencyRank": 320 }, + { "id": "v12-neskor", "sk": "neskôr", "en": "later", "partOfSpeech": "adverb", "exampleSk": "Ozvem sa neskôr.", "exampleEn": "I'll get in touch later.", "register": "informal", "frequencyRank": 270 }, + { "id": "v12-ozvem", "sk": "ozvem sa", "en": "I'll get in touch", "partOfSpeech": "verb", "exampleSk": "Ozvem sa večer.", "exampleEn": "I'll get in touch in the evening.", "register": "informal", "frequencyRank": 350 } + ], + "lessons": [ + { + "id": "l12-cp-teach", + "title": "Čau, čo robíš?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e12-cpt-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Čau, čo nové?", + "answer": "Hey, what's new?", + "acceptable": ["hey, what's new?", "hi, what's new?", "hey what's up?", "hi what's new"], + "vocabCardId": "v12-co-nove", + "register": "informal" + }, + { + "id": "e12-cpt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "A friend texts 'Ideme na pivo, ideš?'. What are they asking?", + "answer": "We're going for a beer, you in?", + "acceptable": ["we're going for a beer, you in?"], + "choices": ["We're going for a beer, you in?", "Where's the beer?", "Do you like beer?", "Is the beer good?"], + "vocabCardId": "v12-ides", + "register": "informal" + }, + { + "id": "e12-cpt-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "How do you reply 'I can't today.'?", + "answer": "Dnes nemôžem.", + "acceptable": ["dnes nemôžem."], + "choices": ["Dnes nemôžem.", "Dnes môžem.", "Zajtra nemôžem.", "Dnes nechcem."], + "vocabCardId": "v12-mozem", + "register": "informal" + }, + { + "id": "e12-cpt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: how does the friend confirm?", + "answer": "Super, dohodnuté!", + "acceptable": ["super, dohodnuté!"], + "choices": ["Super, dohodnuté!", "Nie, nemôžem.", "Možno neskôr.", "Neviem."], + "audioKey": "super-dohodnute", + "vocabCardId": "v12-dohodnute", + "register": "informal" + }, + { + "id": "e12-cpt-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the chat phrase to its meaning.", + "answer": "", + "pairs": [["čau", "hi / bye"], ["čo nové", "what's new"], ["dohodnuté", "deal"], ["neskôr", "later"], ["ozvem sa", "I'll get in touch"]], + "register": "informal" + }, + { + "id": "e12-cpt-6", + "orderIndex": 5, + "type": "DIALOGUE_FILL", + "promptEn": "— Čau! Ideme dnes von, ____? — Jasné, idem!", + "promptSk": "— Čau! Ideme dnes von, ____? — Jasné, idem!", + "answer": "ideš", + "acceptable": ["ideš"], + "choices": ["ideš", "idem", "ide", "ideme"], + "hint": "You're asking the other person: 2nd person 'ideš'.", + "vocabCardId": "v12-ides", + "register": "informal" + } + ] + }, + { + "id": "l12-cp-practice", + "title": "Dohodneme sa cez chat", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e12-cpp-1", + "orderIndex": 0, + "type": "DIALOGUE_FILL", + "promptEn": "— O koľkej sa stretneme? — ____ siedmej pri kine.", + "promptSk": "— O koľkej sa stretneme? — ____ siedmej pri kine.", + "answer": "O", + "acceptable": ["o"], + "choices": ["O", "Na", "Do", "Za"], + "hint": "Telling time uses 'o' + locative.", + "register": "informal" + }, + { + "id": "e12-cpp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "— Ideš dnes na kávu? — Dnes ____, mám veľa roboty. (I can't)", + "promptSk": "— Ideš dnes na kávu? — Dnes ____, mám veľa roboty.", + "answer": "nemôžem", + "acceptable": ["nemôžem"], + "choices": ["nemôžem", "môžem", "nechcem", "neviem"], + "vocabCardId": "v12-mozem", + "register": "informal" + }, + { + "id": "e12-cpp-3", + "orderIndex": 2, + "type": "DIALOGUE_FILL", + "promptEn": "— Tak teda zajtra? — Super, ____! (deal)", + "promptSk": "— Tak teda zajtra? — Super, ____!", + "answer": "dohodnuté", + "acceptable": ["dohodnuté"], + "choices": ["dohodnuté", "neskôr", "možno", "ahoj"], + "vocabCardId": "v12-dohodnute", + "register": "informal" + }, + { + "id": "e12-cpp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "Text me later.", + "answer": "Napíš mi neskôr.", + "acceptable": ["napíš mi neskôr.", "napíš mi neskôr"], + "vocabCardId": "v12-napis", + "register": "informal" + }, + { + "id": "e12-cpp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'll get in touch in the evening.", + "answer": "Ozvem sa večer.", + "acceptable": ["ozvem sa večer.", "ozvem sa večer"], + "vocabCardId": "v12-ozvem", + "register": "informal" + }, + { + "id": "e12-cpp-6", + "orderIndex": 5, + "type": "WORD_BANK", + "promptEn": "Build: 'We're going for a beer, you in?'", + "answer": "Ideme na pivo, ideš?", + "acceptable": ["ideme na pivo, ideš?", "ideme na pivo, ideš"], + "choices": ["Ideme", "na", "pivo,", "ideš?", "dnes"], + "vocabCardId": "v12-ides", + "register": "informal" + }, + { + "id": "e12-cpp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Reply to a friend: 'Great, it's a deal, see you at seven!'", + "promptSk": "Super, dohodnuté, vidíme sa o siedmej!", + "answer": "Super, dohodnuté, vidíme sa o siedmej!", + "acceptable": ["super, dohodnuté, vidíme sa o siedmej!", "super, dohodnuté, vidíme sa o siedmej"], + "audioKey": "super-dohodnute-vidime-sa-o-siedmej", + "vocabCardId": "v12-dohodnute", + "register": "informal" + } + ] + } + ] + }, + { + "id": "s12-chat-zivot", + "unitId": "u12", + "title": "Ako sa máš?", + "description": "Longer WhatsApp threads catching up on life: how it's going, work and feelings, past tense (bol som), and reacting naturally. The Chat-with-Friends north star.", + "iconKey": "chat", + "colorKey": "purple", + "orderIndex": 1, + "cefrLevel": "A2", + "vocab": [ + { "id": "v12-ako-sa-mas", "sk": "ako sa máš", "en": "how are you", "partOfSpeech": "phrase", "exampleSk": "Ahoj, ako sa máš?", "exampleEn": "Hi, how are you?", "register": "informal", "frequencyRank": 70 }, + { "id": "v12-celkom", "sk": "celkom dobre", "en": "pretty good", "partOfSpeech": "phrase", "exampleSk": "Mám sa celkom dobre.", "exampleEn": "I'm doing pretty good.", "register": "informal", "frequencyRank": 240 }, + { "id": "v12-unaveny", "sk": "unavený", "en": "tired", "partOfSpeech": "adjective", "exampleSk": "Som dosť unavený.", "exampleEn": "I'm pretty tired.", "register": "informal", "frequencyRank": 520 }, + { "id": "v12-skoda", "sk": "škoda", "en": "shame / too bad", "partOfSpeech": "interjection", "exampleSk": "To je škoda.", "exampleEn": "That's a shame.", "register": "informal", "frequencyRank": 460 }, + { "id": "v12-drz-sa", "sk": "drž sa", "en": "take care / hang in there", "partOfSpeech": "phrase", "exampleSk": "Drž sa, ozvem sa.", "exampleEn": "Take care, I'll be in touch.", "register": "informal", "frequencyRank": 640 }, + { "id": "v12-vlastne", "sk": "vlastne", "en": "actually", "partOfSpeech": "adverb", "exampleSk": "Vlastne mám dobrú správu.", "exampleEn": "Actually I have good news.", "register": "informal", "frequencyRank": 290 }, + { "id": "v12-bol-som", "sk": "bol som", "en": "I was", "partOfSpeech": "verb", "exampleSk": "Včera som bol u rodičov.", "exampleEn": "Yesterday I was at my parents'.", "register": "informal", "frequencyRank": 80 }, + { "id": "v12-tesim-sa", "sk": "teším sa", "en": "I'm looking forward to it", "partOfSpeech": "verb", "exampleSk": "Teším sa na teba.", "exampleEn": "I'm looking forward to seeing you.", "register": "informal", "frequencyRank": 360 } + ], + "lessons": [ + { + "id": "l12-cz-teach", + "title": "Ahoj, ako sa máš?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e12-czt-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Ahoj, ako sa máš?", + "answer": "Hi, how are you?", + "acceptable": ["hi, how are you?", "hi how are you", "hello, how are you?"], + "vocabCardId": "v12-ako-sa-mas", + "register": "informal" + }, + { + "id": "e12-czt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "A friend writes 'Mám sa celkom dobre.' What does it mean?", + "answer": "I'm doing pretty good.", + "acceptable": ["i'm doing pretty good."], + "choices": ["I'm doing pretty good.", "I'm not well.", "I'm very busy.", "I'm at home."], + "vocabCardId": "v12-celkom", + "register": "informal" + }, + { + "id": "e12-czt-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Which reply means 'That's a shame.'?", + "answer": "To je škoda.", + "acceptable": ["to je škoda."], + "choices": ["To je škoda.", "To je super.", "To je jedno.", "To je všetko."], + "vocabCardId": "v12-skoda", + "register": "informal" + }, + { + "id": "e12-czt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: how does the friend sign off?", + "answer": "Drž sa, ozvem sa.", + "acceptable": ["drž sa, ozvem sa."], + "choices": ["Drž sa, ozvem sa.", "Dobrú noc.", "Vidíme sa.", "Ďakujem pekne."], + "audioKey": "drz-sa-ozvem-sa", + "vocabCardId": "v12-drz-sa", + "register": "informal" + }, + { + "id": "e12-czt-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the chat reaction to its meaning.", + "answer": "", + "pairs": [["ako sa máš", "how are you"], ["celkom dobre", "pretty good"], ["škoda", "shame"], ["vlastne", "actually"], ["teším sa", "looking forward"]], + "register": "informal" + }, + { + "id": "e12-czt-6", + "orderIndex": 5, + "type": "DIALOGUE_FILL", + "promptEn": "— Ahoj, ako sa máš? — Mám sa ____ dobre, a ty? (pretty)", + "promptSk": "— Ako sa máš? — Mám sa ____ dobre, a ty?", + "answer": "celkom", + "acceptable": ["celkom"], + "choices": ["celkom", "veľmi", "vôbec", "stále"], + "vocabCardId": "v12-celkom", + "register": "informal" + } + ] + }, + { + "id": "l12-cz-practice", + "title": "Dlhší rozhovor", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e12-czp-1", + "orderIndex": 0, + "type": "DIALOGUE_FILL", + "promptEn": "— Čo si robil cez víkend? — ____ som u rodičov. (I was)", + "promptSk": "— Čo si robil cez víkend? — ____ som u rodičov.", + "answer": "Bol", + "acceptable": ["bol"], + "choices": ["Bol", "Som", "Budem", "Byť"], + "hint": "Past tense (male): bol + som.", + "vocabCardId": "v12-bol-som", + "register": "informal" + }, + { + "id": "e12-czp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "— Prídeš zajtra? — Nemôžem, som dosť ____. (tired)", + "promptSk": "— Prídeš zajtra? — Nemôžem, som dosť ____.", + "answer": "unavený", + "acceptable": ["unavený"], + "choices": ["unavený", "unavená", "unavené", "unavení"], + "hint": "Male speaker: masculine -ý.", + "vocabCardId": "v12-unaveny", + "register": "informal" + }, + { + "id": "e12-czp-3", + "orderIndex": 2, + "type": "DIALOGUE_FILL", + "promptEn": "— Nestihnem to dnes. — ____, tak zajtra. (too bad)", + "promptSk": "— Nestihnem to dnes. — ____, tak zajtra.", + "answer": "Škoda", + "acceptable": ["škoda"], + "choices": ["Škoda", "Super", "Jasné", "Hej"], + "vocabCardId": "v12-skoda", + "register": "informal" + }, + { + "id": "e12-czp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "Actually I have good news.", + "answer": "Vlastne mám dobrú správu.", + "acceptable": ["vlastne mám dobrú správu.", "vlastne mám dobrú správu"], + "vocabCardId": "v12-vlastne", + "register": "informal" + }, + { + "id": "e12-czp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm looking forward to seeing you.", + "answer": "Teším sa na teba.", + "acceptable": ["teším sa na teba.", "teším sa na teba"], + "vocabCardId": "v12-tesim-sa", + "register": "informal" + }, + { + "id": "e12-czp-6", + "orderIndex": 5, + "type": "WORD_BANK", + "promptEn": "Build the past tense: 'Yesterday I was at my parents'.' (male)", + "answer": "Včera som bol u rodičov.", + "acceptable": ["včera som bol u rodičov.", "včera som bol u rodičov"], + "choices": ["Včera", "som", "bol", "u", "rodičov.", "budem"], + "hint": "After 'Včera' the clitic 'som' comes second.", + "vocabCardId": "v12-bol-som", + "register": "informal" + }, + { + "id": "e12-czp-7", + "orderIndex": 6, + "type": "TRANSLATE_EN_SK", + "promptEn": "Take care, I'll be in touch later.", + "answer": "Drž sa, ozvem sa neskôr.", + "acceptable": ["drž sa, ozvem sa neskôr.", "drž sa, ozvem sa neskôr"], + "vocabCardId": "v12-drz-sa", + "register": "informal" + }, + { + "id": "e12-czp-8", + "orderIndex": 7, + "type": "SPEAK", + "promptEn": "Reply warmly: 'I'm pretty tired, but I'm looking forward to seeing you.'", + "promptSk": "Som dosť unavený, ale teším sa na teba.", + "answer": "Som dosť unavený, ale teším sa na teba.", + "acceptable": ["som dosť unavený, ale teším sa na teba.", "som dosť unavený, ale teším sa na teba"], + "audioKey": "som-dost-unaveny-ale-tesim-sa-na-teba", + "vocabCardId": "v12-tesim-sa", + "register": "informal" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit2-ja-a-ty.json b/slovko/app/src/main/assets/curriculum/unit2-ja-a-ty.json new file mode 100644 index 0000000..11454db --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit2-ja-a-ty.json @@ -0,0 +1,354 @@ +{ + "unit": { "id": "u2", "name": "Ja a ty", "cefr": "A1", "order": 2 }, + "skills": [ + { + "id": "s2-zamena-byt", + "unitId": "u2", + "title": "Zámená a byť", + "description": "Personal pronouns and the present tense of 'byť' (to be).", + "iconKey": "person", + "colorKey": "blue", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v2-ja", "sk": "ja", "en": "I", "partOfSpeech": "pronoun", "exampleSk": "Ja som tu.", "exampleEn": "I am here.", "register": "neutral", "frequencyRank": 15 }, + { "id": "v2-ty", "sk": "ty", "en": "you (informal)", "partOfSpeech": "pronoun", "exampleSk": "Ty si Slovák?", "exampleEn": "Are you Slovak?", "register": "informal", "frequencyRank": 25 }, + { "id": "v2-on", "sk": "on", "en": "he", "partOfSpeech": "pronoun", "exampleSk": "On je doma.", "exampleEn": "He is at home.", "register": "neutral", "frequencyRank": 30 }, + { "id": "v2-ona", "sk": "ona", "en": "she", "partOfSpeech": "pronoun", "exampleSk": "Ona je učiteľka.", "exampleEn": "She is a teacher.", "register": "neutral", "frequencyRank": 35 }, + { "id": "v2-my", "sk": "my", "en": "we", "partOfSpeech": "pronoun", "exampleSk": "My sme tu.", "exampleEn": "We are here.", "register": "neutral", "frequencyRank": 40 }, + { "id": "v2-som", "sk": "som", "en": "(I) am", "partOfSpeech": "verb", "exampleSk": "Som unavený.", "exampleEn": "I am tired.", "register": "neutral", "frequencyRank": 10 }, + { "id": "v2-si", "sk": "si", "en": "(you) are", "partOfSpeech": "verb", "exampleSk": "Si pripravený?", "exampleEn": "Are you ready?", "register": "informal", "frequencyRank": 45 }, + { "id": "v2-je", "sk": "je", "en": "(he/she/it) is", "partOfSpeech": "verb", "exampleSk": "Je to dobré.", "exampleEn": "It is good.", "register": "neutral", "frequencyRank": 5 } + ], + "lessons": [ + { + "id": "l2-byt-teach", + "title": "Som, si, je", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e2-bt-1", + "orderIndex": 0, + "type": "MATCH_PAIRS", + "promptEn": "Match the pronouns.", + "answer": "", + "acceptable": [], + "pairs": [ + ["ja", "I"], + ["ty", "you"], + ["on", "he"], + ["ona", "she"], + ["my", "we"] + ], + "register": "neutral" + }, + { + "id": "e2-bt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "Choose the correct form: 'Ja ___ tu.'", + "answer": "som", + "acceptable": ["som"], + "choices": ["som", "si", "je", "sme"], + "vocabCardId": "v2-som", + "register": "neutral" + }, + { + "id": "e2-bt-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Choose the correct form: 'Ty ___ Slovák?'", + "answer": "si", + "acceptable": ["si"], + "choices": ["si", "som", "je", "ste"], + "vocabCardId": "v2-si", + "register": "informal" + }, + { + "id": "e2-bt-4", + "orderIndex": 3, + "type": "TRANSLATE_SK_EN", + "promptSk": "Ona je učiteľka.", + "answer": "She is a teacher.", + "acceptable": ["she is a teacher", "she's a teacher"], + "vocabCardId": "v2-ona", + "register": "neutral" + }, + { + "id": "e2-bt-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Choose: 'My ___ tu.'", + "answer": "sme", + "acceptable": ["sme"], + "choices": ["sme", "som", "ste", "sú"], + "vocabCardId": "v2-my", + "register": "neutral" + }, + { + "id": "e2-bt-6", + "orderIndex": 5, + "type": "WORD_BANK", + "promptEn": "Build: 'I am tired.'", + "answer": "Som unavený.", + "acceptable": ["som unavený", "som unavený."], + "choices": ["unavený.", "Som"], + "register": "neutral" + } + ] + }, + { + "id": "l2-byt-practice", + "title": "Otázky a odpovede", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e2-bp-1", + "orderIndex": 0, + "type": "DIALOGUE_FILL", + "promptEn": "Fill: 'On ___ doma.' (He is at home.)", + "promptSk": "On ___ doma.", + "answer": "je", + "acceptable": ["je"], + "choices": ["je", "som", "si", "sú"], + "vocabCardId": "v2-je", + "register": "neutral" + }, + { + "id": "e2-bp-2", + "orderIndex": 1, + "type": "TRANSLATE_EN_SK", + "promptEn": "Are you ready?", + "answer": "Si pripravený?", + "acceptable": ["si pripravený?", "si pripravený", "si pripravená?", "si pripravená"], + "vocabCardId": "v2-si", + "register": "informal" + }, + { + "id": "e2-bp-3", + "orderIndex": 2, + "type": "LISTEN_CHOOSE", + "promptEn": "Which 'to be' form do you hear?", + "answer": "je", + "acceptable": ["je"], + "choices": ["je", "som", "si", "sme"], + "vocabCardId": "v2-je", + "audioKey": "je", + "register": "neutral" + }, + { + "id": "e2-bp-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build: 'Are you Slovak?'", + "answer": "Ty si Slovák?", + "acceptable": ["ty si slovák?", "ty si slovák", "si slovák?"], + "choices": ["Slovák?", "Ty", "si"], + "register": "informal" + }, + { + "id": "e2-bp-5", + "orderIndex": 4, + "type": "TRANSLATE_SK_EN", + "promptSk": "Je to dobré.", + "answer": "It is good.", + "acceptable": ["it is good", "it's good", "that is good", "that's good"], + "vocabCardId": "v2-je", + "register": "neutral" + }, + { + "id": "e2-bp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'I am here.'", + "promptSk": "Ja som tu.", + "answer": "Ja som tu.", + "acceptable": ["ja som tu", "som tu"], + "audioKey": "ja-som-tu", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s2-odkial-cisla", + "unitId": "u2", + "title": "Odkiaľ si a čísla", + "description": "Saying where you're from, nationalities, languages, and numbers 0-20.", + "iconKey": "number", + "colorKey": "teal", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v2-som-z", "sk": "som z", "en": "I'm from", "partOfSpeech": "phrase", "exampleSk": "Som zo Slovenska.", "exampleEn": "I'm from Slovakia.", "register": "neutral", "frequencyRank": 160 }, + { "id": "v2-slovensko", "sk": "Slovensko", "en": "Slovakia", "partOfSpeech": "noun", "gender": "neuter", "exampleSk": "Slovensko je krásne.", "exampleEn": "Slovakia is beautiful.", "register": "neutral", "frequencyRank": 170 }, + { "id": "v2-slovak", "sk": "Slovák", "en": "Slovak (man)", "partOfSpeech": "noun", "gender": "masculine", "exampleSk": "Som Slovák.", "exampleEn": "I'm Slovak.", "register": "neutral", "frequencyRank": 180 }, + { "id": "v2-hovoris", "sk": "hovoríš po anglicky", "en": "do you speak English", "partOfSpeech": "phrase", "exampleSk": "Hovoríš po anglicky?", "exampleEn": "Do you speak English?", "register": "informal", "frequencyRank": 190 }, + { "id": "v2-jeden", "sk": "jeden", "en": "one", "partOfSpeech": "numeral", "exampleSk": "Jeden, dva, tri.", "exampleEn": "One, two, three.", "register": "neutral", "frequencyRank": 100 }, + { "id": "v2-dva", "sk": "dva", "en": "two", "partOfSpeech": "numeral", "exampleSk": "Mám dva psy.", "exampleEn": "I have two dogs.", "register": "neutral", "frequencyRank": 105 }, + { "id": "v2-tri", "sk": "tri", "en": "three", "partOfSpeech": "numeral", "exampleSk": "Tri kávy, prosím.", "exampleEn": "Three coffees, please.", "register": "neutral", "frequencyRank": 115 }, + { "id": "v2-desat", "sk": "desať", "en": "ten", "partOfSpeech": "numeral", "exampleSk": "Mám desať eur.", "exampleEn": "I have ten euros.", "register": "neutral", "frequencyRank": 125 } + ], + "lessons": [ + { + "id": "l2-odkial-teach", + "title": "Som zo Slovenska", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e2-ot-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "How do you say 'I'm from'?", + "answer": "som z", + "acceptable": ["som z"], + "choices": ["som z", "som tu", "volám sa", "mám sa"], + "vocabCardId": "v2-som-z", + "register": "neutral" + }, + { + "id": "e2-ot-2", + "orderIndex": 1, + "type": "TRANSLATE_SK_EN", + "promptSk": "Som zo Slovenska.", + "answer": "I'm from Slovakia.", + "acceptable": ["i'm from slovakia", "i am from slovakia"], + "vocabCardId": "v2-som-z", + "audioKey": "som-zo-slovenska", + "register": "neutral" + }, + { + "id": "e2-ot-3", + "orderIndex": 2, + "type": "LISTEN_CHOOSE", + "promptEn": "Which question do you hear?", + "answer": "Hovoríš po anglicky?", + "acceptable": ["hovoríš po anglicky?", "hovoríš po anglicky"], + "choices": ["Hovoríš po anglicky?", "Ako sa máš?", "Odkiaľ si?", "Kde si?"], + "vocabCardId": "v2-hovoris", + "audioKey": "hovoris-po-anglicky", + "register": "informal" + }, + { + "id": "e2-ot-4", + "orderIndex": 3, + "type": "MCQ", + "promptEn": "How do you ask informally 'Where are you from?'", + "answer": "Odkiaľ si?", + "acceptable": ["odkiaľ si?", "odkiaľ si"], + "choices": ["Odkiaľ si?", "Kto si?", "Čo si?", "Kedy si?"], + "register": "informal" + }, + { + "id": "e2-ot-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'Do you speak English?'", + "answer": "Hovoríš po anglicky?", + "acceptable": ["hovoríš po anglicky?", "hovoríš po anglicky"], + "choices": ["po", "Hovoríš", "anglicky?"], + "register": "informal" + }, + { + "id": "e2-ot-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'I'm Slovak.'", + "promptSk": "Som Slovák.", + "answer": "Som Slovák.", + "acceptable": ["som slovák", "som slovenka"], + "vocabCardId": "v2-slovak", + "audioKey": "som-slovak", + "register": "neutral" + } + ] + }, + { + "id": "l2-cisla-practice", + "title": "Čísla 0-20", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e2-cp-1", + "orderIndex": 0, + "type": "MATCH_PAIRS", + "promptEn": "Match the numbers.", + "answer": "", + "acceptable": [], + "pairs": [ + ["jeden", "1"], + ["dva", "2"], + ["tri", "3"], + ["desať", "10"] + ], + "register": "neutral" + }, + { + "id": "e2-cp-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "What is 'five' in Slovak?", + "answer": "päť", + "acceptable": ["päť"], + "choices": ["päť", "štyri", "šesť", "sedem"], + "audioKey": "pat", + "register": "neutral" + }, + { + "id": "e2-cp-3", + "orderIndex": 2, + "type": "LISTEN_TYPE", + "promptEn": "Type the number you hear (as a Slovak word).", + "answer": "sedem", + "acceptable": ["sedem"], + "audioKey": "sedem", + "hint": "It's 7.", + "register": "neutral" + }, + { + "id": "e2-cp-4", + "orderIndex": 3, + "type": "MCQ", + "promptEn": "What is 'twelve'?", + "answer": "dvanásť", + "acceptable": ["dvanásť"], + "choices": ["dvanásť", "jedenásť", "trinásť", "dvadsať"], + "audioKey": "dvanast", + "register": "neutral" + }, + { + "id": "e2-cp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "Three coffees, please.", + "answer": "Tri kávy, prosím.", + "acceptable": ["tri kávy, prosím", "tri kávy prosím"], + "vocabCardId": "v2-tri", + "register": "neutral" + }, + { + "id": "e2-cp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Count out loud: one, two, three.", + "promptSk": "jeden, dva, tri", + "answer": "jeden, dva, tri", + "acceptable": ["jeden, dva, tri", "jeden dva tri"], + "audioKey": "jeden-dva-tri", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit3-rodina.json b/slovko/app/src/main/assets/curriculum/unit3-rodina.json new file mode 100644 index 0000000..cd0ed30 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit3-rodina.json @@ -0,0 +1,358 @@ +{ + "unit": { "id": "u3", "name": "Rodina", "cefr": "A1", "order": 3 }, + "skills": [ + { + "id": "s3-rodina-zdrobneniny", + "unitId": "u3", + "title": "Rodina a zdrobneniny", + "description": "Family members and diminutives like mama to mamka, otec to ocko.", + "iconKey": "family", + "colorKey": "pink", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v3-mama", "sk": "mama", "en": "mom", "partOfSpeech": "noun", "gender": "feminine", "exampleSk": "Moja mama varí.", "exampleEn": "My mom is cooking.", "register": "neutral", "frequencyRank": 60 }, + { "id": "v3-mamka", "sk": "mamka", "en": "mommy", "partOfSpeech": "noun", "gender": "feminine", "exampleSk": "Mamka, kde si?", "exampleEn": "Mommy, where are you?", "register": "informal", "frequencyRank": 210 }, + { "id": "v3-otec", "sk": "otec", "en": "father", "partOfSpeech": "noun", "gender": "masculine", "exampleSk": "Môj otec pracuje.", "exampleEn": "My father works.", "register": "neutral", "frequencyRank": 65 }, + { "id": "v3-ocko", "sk": "ocko", "en": "daddy", "partOfSpeech": "noun", "gender": "masculine", "exampleSk": "Ocko, poď sem.", "exampleEn": "Daddy, come here.", "register": "informal", "frequencyRank": 220 }, + { "id": "v3-brat", "sk": "brat", "en": "brother", "partOfSpeech": "noun", "gender": "masculine", "exampleSk": "Mám jedného brata.", "exampleEn": "I have one brother.", "register": "neutral", "frequencyRank": 90 }, + { "id": "v3-sestra", "sk": "sestra", "en": "sister", "partOfSpeech": "noun", "gender": "feminine", "exampleSk": "Moja sestra je doma.", "exampleEn": "My sister is at home.", "register": "neutral", "frequencyRank": 95 }, + { "id": "v3-babka", "sk": "babka", "en": "grandma", "partOfSpeech": "noun", "gender": "feminine", "exampleSk": "Babka pečie koláče.", "exampleEn": "Grandma bakes cakes.", "register": "informal", "frequencyRank": 230 }, + { "id": "v3-dedko", "sk": "dedko", "en": "grandpa", "partOfSpeech": "noun", "gender": "masculine", "exampleSk": "Dedko číta noviny.", "exampleEn": "Grandpa reads the newspaper.", "register": "informal", "frequencyRank": 240 } + ], + "lessons": [ + { + "id": "l3-rodina-teach", + "title": "Mama, otec, súrodenci", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e3-rt-1", + "orderIndex": 0, + "type": "MATCH_PAIRS", + "promptEn": "Match the family members.", + "answer": "", + "acceptable": [], + "pairs": [ + ["mama", "mom"], + ["otec", "father"], + ["brat", "brother"], + ["sestra", "sister"] + ], + "register": "neutral" + }, + { + "id": "e3-rt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "Which word is the affectionate 'mommy'?", + "answer": "mamka", + "acceptable": ["mamka"], + "choices": ["mamka", "mama", "sestra", "babka"], + "vocabCardId": "v3-mamka", + "hint": "Diminutive of mama.", + "register": "informal" + }, + { + "id": "e3-rt-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "What is the diminutive of 'otec'?", + "answer": "ocko", + "acceptable": ["ocko"], + "choices": ["ocko", "dedko", "brat", "mamka"], + "vocabCardId": "v3-ocko", + "register": "informal" + }, + { + "id": "e3-rt-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Which word do you hear?", + "answer": "babka", + "acceptable": ["babka"], + "choices": ["babka", "dedko", "mamka", "sestra"], + "vocabCardId": "v3-babka", + "audioKey": "babka", + "register": "informal" + }, + { + "id": "e3-rt-5", + "orderIndex": 4, + "type": "TRANSLATE_SK_EN", + "promptSk": "Moja sestra je doma.", + "answer": "My sister is at home.", + "acceptable": ["my sister is at home", "my sister is home"], + "vocabCardId": "v3-sestra", + "register": "neutral" + }, + { + "id": "e3-rt-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'My father works.'", + "promptSk": "Môj otec pracuje.", + "answer": "Môj otec pracuje.", + "acceptable": ["môj otec pracuje", "môj otec pracuje."], + "audioKey": "moj-otec-pracuje", + "register": "neutral" + } + ] + }, + { + "id": "l3-posesiva-practice", + "title": "Môj, moja, moje", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e3-pp-1", + "orderIndex": 0, + "type": "FILL_CASE", + "promptEn": "Fill the possessive (masculine): '___ brat' (my brother)", + "promptSk": "___ brat", + "answer": "môj", + "acceptable": ["môj"], + "choices": ["môj", "moja", "moje", "moji"], + "hint": "Masculine noun takes môj.", + "caseTag": "nominative", + "register": "neutral" + }, + { + "id": "e3-pp-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "Fill the possessive (feminine): '___ sestra' (my sister)", + "promptSk": "___ sestra", + "answer": "moja", + "acceptable": ["moja"], + "choices": ["moja", "môj", "moje", "moji"], + "hint": "Feminine noun takes moja.", + "caseTag": "nominative", + "register": "neutral" + }, + { + "id": "e3-pp-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "Fill the possessive (neuter): '___ dieťa' (my child)", + "promptSk": "___ dieťa", + "answer": "moje", + "acceptable": ["moje"], + "choices": ["moje", "môj", "moja", "moji"], + "hint": "Neuter noun takes moje.", + "caseTag": "nominative", + "register": "neutral" + }, + { + "id": "e3-pp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "My mom is cooking.", + "answer": "Moja mama varí.", + "acceptable": ["moja mama varí", "moja mama varí."], + "vocabCardId": "v3-mama", + "register": "neutral" + }, + { + "id": "e3-pp-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'My father works.'", + "answer": "Môj otec pracuje.", + "acceptable": ["môj otec pracuje", "môj otec pracuje."], + "choices": ["pracuje.", "Môj", "otec"], + "register": "neutral" + }, + { + "id": "e3-pp-6", + "orderIndex": 5, + "type": "MCQ", + "promptEn": "Which is correct: 'moja / môj' with 'babka'?", + "answer": "moja babka", + "acceptable": ["moja babka", "moja"], + "choices": ["moja babka", "môj babka", "moje babka", "moji babka"], + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s3-mam-vek", + "unitId": "u3", + "title": "Mám... vek a zvieratá", + "description": "Using mám (I have) with the accusative case, telling age, and pets.", + "iconKey": "pet", + "colorKey": "yellow", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v3-mam", "sk": "mám", "en": "I have", "partOfSpeech": "verb", "exampleSk": "Mám psa.", "exampleEn": "I have a dog.", "register": "neutral", "frequencyRank": 30 }, + { "id": "v3-pes", "sk": "pes", "en": "dog", "partOfSpeech": "noun", "gender": "masculine", "exampleSk": "Pes spí.", "exampleEn": "The dog is sleeping.", "register": "neutral", "frequencyRank": 140 }, + { "id": "v3-macka", "sk": "mačka", "en": "cat", "partOfSpeech": "noun", "gender": "feminine", "exampleSk": "Mačka je malá.", "exampleEn": "The cat is small.", "register": "neutral", "frequencyRank": 150 }, + { "id": "v3-rokov", "sk": "rokov", "en": "years (old)", "partOfSpeech": "noun", "gender": "masculine", "exampleSk": "Mám tridsať rokov.", "exampleEn": "I'm thirty years old.", "register": "neutral", "frequencyRank": 250 }, + { "id": "v3-kolko", "sk": "koľko máš rokov", "en": "how old are you", "partOfSpeech": "phrase", "exampleSk": "Koľko máš rokov?", "exampleEn": "How old are you?", "register": "informal", "frequencyRank": 260 } + ], + "lessons": [ + { + "id": "l3-mam-teach", + "title": "Mám psa, mám mačku", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e3-mt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "How do you say 'I have'?", + "answer": "mám", + "acceptable": ["mám"], + "choices": ["mám", "som", "je", "si"], + "vocabCardId": "v3-mam", + "register": "neutral" + }, + { + "id": "e3-mt-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "Accusative: 'Mám ___.' (a dog — pes becomes accusative)", + "promptSk": "Mám ___.", + "answer": "psa", + "acceptable": ["psa"], + "choices": ["psa", "pes", "psu", "psom"], + "hint": "Masculine animate accusative ends in -a.", + "caseTag": "accusative", + "register": "neutral" + }, + { + "id": "e3-mt-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "Accusative: 'Mám ___.' (a cat — mačka becomes accusative)", + "promptSk": "Mám ___.", + "answer": "mačku", + "acceptable": ["mačku"], + "choices": ["mačku", "mačka", "mačke", "mačkou"], + "hint": "Feminine accusative ends in -u.", + "caseTag": "accusative", + "register": "neutral" + }, + { + "id": "e3-mt-4", + "orderIndex": 3, + "type": "TRANSLATE_SK_EN", + "promptSk": "Mám psa.", + "answer": "I have a dog.", + "acceptable": ["i have a dog", "i have a dog."], + "vocabCardId": "v3-mam", + "audioKey": "mam-psa", + "register": "neutral" + }, + { + "id": "e3-mt-5", + "orderIndex": 4, + "type": "LISTEN_CHOOSE", + "promptEn": "Which animal do you hear?", + "answer": "mačka", + "acceptable": ["mačka"], + "choices": ["mačka", "pes", "babka", "brat"], + "vocabCardId": "v3-macka", + "audioKey": "macka", + "register": "neutral" + }, + { + "id": "e3-mt-6", + "orderIndex": 5, + "type": "WORD_BANK", + "promptEn": "Build: 'I have a cat.'", + "answer": "Mám mačku.", + "acceptable": ["mám mačku", "mám mačku."], + "choices": ["mačku.", "Mám"], + "register": "neutral" + } + ] + }, + { + "id": "l3-vek-practice", + "title": "Koľko máš rokov?", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e3-vp-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Koľko máš rokov?", + "answer": "How old are you?", + "acceptable": ["how old are you", "how old are you?"], + "vocabCardId": "v3-kolko", + "audioKey": "kolko-mas-rokov", + "register": "informal" + }, + { + "id": "e3-vp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "Fill: 'Mám dvadsať ___.' (I'm twenty years old.)", + "promptSk": "Mám dvadsať ___.", + "answer": "rokov", + "acceptable": ["rokov"], + "choices": ["rokov", "roky", "rok", "roka"], + "vocabCardId": "v3-rokov", + "hint": "After numbers 5+ use 'rokov'.", + "register": "neutral" + }, + { + "id": "e3-vp-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Choose the right age phrase: 'I have one year' uses:", + "answer": "rok", + "acceptable": ["rok"], + "choices": ["rok", "roky", "rokov", "roka"], + "hint": "One = jeden rok.", + "register": "neutral" + }, + { + "id": "e3-vp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm thirty years old.", + "answer": "Mám tridsať rokov.", + "acceptable": ["mám tridsať rokov", "mám tridsať rokov."], + "vocabCardId": "v3-rokov", + "register": "neutral" + }, + { + "id": "e3-vp-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'How old are you?'", + "answer": "Koľko máš rokov?", + "acceptable": ["koľko máš rokov?", "koľko máš rokov"], + "choices": ["máš", "Koľko", "rokov?"], + "register": "informal" + }, + { + "id": "e3-vp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'I have a dog and a cat.'", + "promptSk": "Mám psa a mačku.", + "answer": "Mám psa a mačku.", + "acceptable": ["mám psa a mačku", "mám psa a mačku."], + "audioKey": "mam-psa-a-macku", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit4-jedlo-kava.json b/slovko/app/src/main/assets/curriculum/unit4-jedlo-kava.json new file mode 100644 index 0000000..46028fd --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit4-jedlo-kava.json @@ -0,0 +1,392 @@ +{ + "unit": { "id": "u4", "name": "Jedlo a káva", "cefr": "A1", "order": 4 }, + "skills": [ + { + "id": "s4-jedlo", + "unitId": "u4", + "title": "Jedlo a pitie", + "description": "Food and drink basics: naming common items and saying what you have and like.", + "iconKey": "food", + "colorKey": "orange", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v4-jedlo", "sk": "jedlo", "en": "food / meal", "partOfSpeech": "noun", "gender": "n", "exampleSk": "To jedlo je veľmi dobré.", "exampleEn": "This food is very good.", "register": "neutral", "frequencyRank": 320 }, + { "id": "v4-voda", "sk": "voda", "en": "water", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Dám si pohár vody.", "exampleEn": "I'll have a glass of water.", "register": "neutral", "frequencyRank": 280 }, + { "id": "v4-kava", "sk": "káva", "en": "coffee", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Ráno pijem kávu.", "exampleEn": "I drink coffee in the morning.", "register": "neutral", "frequencyRank": 410 }, + { "id": "v4-caj", "sk": "čaj", "en": "tea", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Daj si čaj.", "exampleEn": "Have some tea.", "register": "neutral", "frequencyRank": 600 }, + { "id": "v4-chlieb", "sk": "chlieb", "en": "bread", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Kupujem chlieb.", "exampleEn": "I'm buying bread.", "register": "neutral", "frequencyRank": 540 }, + { "id": "v4-pivo", "sk": "pivo", "en": "beer", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Jedno pivo, prosím.", "exampleEn": "One beer, please.", "register": "neutral", "frequencyRank": 470 }, + { "id": "v4-polievka", "sk": "polievka", "en": "soup", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Polievka je teplá.", "exampleEn": "The soup is warm.", "register": "neutral", "frequencyRank": 720 }, + { "id": "v4-syr", "sk": "syr", "en": "cheese", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Mám rád syr.", "exampleEn": "I like cheese.", "register": "neutral", "frequencyRank": 810 } + ], + "lessons": [ + { + "id": "l4-jedlo-teach", + "title": "Čo je čo", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e4-jt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'káva' mean?", + "answer": "coffee", + "acceptable": ["coffee"], + "choices": ["coffee", "tea", "water", "beer"], + "audioKey": "kava", + "vocabCardId": "v4-kava", + "register": "neutral" + }, + { + "id": "e4-jt-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the word.", + "answer": "voda", + "acceptable": ["voda"], + "choices": ["voda", "káva", "pivo", "čaj"], + "audioKey": "voda", + "vocabCardId": "v4-voda", + "register": "neutral" + }, + { + "id": "e4-jt-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "chlieb", + "answer": "bread", + "acceptable": ["bread"], + "vocabCardId": "v4-chlieb", + "register": "neutral" + }, + { + "id": "e4-jt-4", + "orderIndex": 3, + "type": "MCQ", + "promptEn": "Which word means 'soup'?", + "answer": "polievka", + "acceptable": ["polievka"], + "choices": ["polievka", "syr", "chlieb", "pivo"], + "vocabCardId": "v4-polievka", + "register": "neutral" + }, + { + "id": "e4-jt-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the food to its meaning.", + "answer": "", + "pairs": [["káva", "coffee"], ["voda", "water"], ["chlieb", "bread"], ["syr", "cheese"], ["pivo", "beer"]], + "register": "neutral" + }, + { + "id": "e4-jt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "tea", + "answer": "čaj", + "acceptable": ["čaj", "caj"], + "vocabCardId": "v4-caj", + "register": "neutral" + }, + { + "id": "e4-jt-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say 'coffee' in Slovak.", + "promptSk": "káva", + "answer": "káva", + "acceptable": ["káva", "kava"], + "audioKey": "kava", + "vocabCardId": "v4-kava", + "register": "neutral" + } + ] + }, + { + "id": "l4-jedlo-practice", + "title": "Mám rád / mám rada", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e4-jp-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Mám rád syr.", + "answer": "I like cheese.", + "acceptable": ["i like cheese", "i like cheese."], + "vocabCardId": "v4-syr", + "register": "neutral" + }, + { + "id": "e4-jp-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "A woman says she likes coffee. Which is correct?", + "answer": "Mám rada kávu.", + "acceptable": ["mám rada kávu."], + "choices": ["Mám rada kávu.", "Mám rád kávu.", "Mám radi kávu.", "Som rada kávu."], + "hint": "rada = feminine speaker.", + "register": "neutral" + }, + { + "id": "e4-jp-3", + "orderIndex": 2, + "type": "DIALOGUE_FILL", + "promptEn": "— Máš rád pivo? — Áno, ____ rád pivo.", + "promptSk": "— Máš rád pivo? — Áno, ____ rád pivo.", + "answer": "mám", + "acceptable": ["mám", "mam"], + "choices": ["mám", "máš", "má", "máte"], + "register": "informal" + }, + { + "id": "e4-jp-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build: 'I like tea.' (male speaker)", + "answer": "Mám rád čaj.", + "acceptable": ["mám rád čaj.", "mám rád čaj"], + "choices": ["Mám", "rád", "čaj.", "rada"], + "register": "neutral" + }, + { + "id": "e4-jp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "I like bread. (female speaker)", + "answer": "Mám rada chlieb.", + "acceptable": ["mám rada chlieb.", "mám rada chlieb", "mam rada chlieb"], + "vocabCardId": "v4-chlieb", + "register": "neutral" + }, + { + "id": "e4-jp-6", + "orderIndex": 5, + "type": "FILL_CASE", + "promptEn": "Mám rád ____ (the noun 'syr' in accusative).", + "promptSk": "Mám rád ____.", + "answer": "syr", + "acceptable": ["syr"], + "choices": ["syr", "syru", "syrom", "syre"], + "hint": "Inanimate masculine accusative = nominative.", + "vocabCardId": "v4-syr", + "register": "neutral", + "caseTag": "accusative" + }, + { + "id": "e4-jp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say 'I like coffee.' (female speaker)", + "promptSk": "Mám rada kávu.", + "answer": "Mám rada kávu.", + "acceptable": ["mám rada kávu.", "mám rada kávu"], + "audioKey": "mam-rada-kavu", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s4-objednavanie", + "unitId": "u4", + "title": "V kaviarni a krčme", + "description": "Ordering in a café or pub: chcem / dám si, máte...?, and the accusative case for food and drink.", + "iconKey": "coffee", + "colorKey": "red", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v4-chcem", "sk": "chcem", "en": "I want", "partOfSpeech": "verb", "exampleSk": "Chcem kávu.", "exampleEn": "I want a coffee.", "register": "neutral", "frequencyRank": 150 }, + { "id": "v4-damsi", "sk": "dám si", "en": "I'll have", "partOfSpeech": "phrase", "exampleSk": "Dám si pivo.", "exampleEn": "I'll have a beer.", "register": "neutral", "frequencyRank": 900 }, + { "id": "v4-mate", "sk": "máte", "en": "do you have (formal)", "partOfSpeech": "verb", "exampleSk": "Máte čaj?", "exampleEn": "Do you have tea?", "register": "formal", "frequencyRank": 210 }, + { "id": "v4-prosim", "sk": "prosím", "en": "please / here you go", "partOfSpeech": "phrase", "exampleSk": "Kávu, prosím.", "exampleEn": "A coffee, please.", "register": "neutral", "frequencyRank": 120 }, + { "id": "v4-ucet", "sk": "účet", "en": "bill / check", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Zaplatím, účet prosím.", "exampleEn": "I'll pay, the bill please.", "register": "neutral", "frequencyRank": 850 }, + { "id": "v4-zaplatim", "sk": "zaplatím", "en": "I'll pay", "partOfSpeech": "verb", "exampleSk": "Zaplatím kartou.", "exampleEn": "I'll pay by card.", "register": "neutral", "frequencyRank": 870 } + ], + "lessons": [ + { + "id": "l4-obj-teach", + "title": "Chcem a dám si", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e4-ot-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Chcem kávu.", + "answer": "I want a coffee.", + "acceptable": ["i want a coffee", "i want a coffee.", "i want coffee"], + "vocabCardId": "v4-chcem", + "register": "neutral" + }, + { + "id": "e4-ot-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "How do you say 'I'll have a beer.'?", + "answer": "Dám si pivo.", + "acceptable": ["dám si pivo."], + "choices": ["Dám si pivo.", "Máte pivo?", "Som pivo.", "Pijem pivo si."], + "audioKey": "dam-si-pivo", + "vocabCardId": "v4-damsi", + "register": "neutral" + }, + { + "id": "e4-ot-3", + "orderIndex": 2, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: what is the customer ordering?", + "answer": "Dám si čaj.", + "acceptable": ["dám si čaj."], + "choices": ["Dám si čaj.", "Dám si kávu.", "Dám si pivo.", "Dám si vodu."], + "audioKey": "dam-si-caj", + "register": "neutral" + }, + { + "id": "e4-ot-4", + "orderIndex": 3, + "type": "MCQ", + "promptEn": "Which means 'Do you have tea?' (formal)?", + "answer": "Máte čaj?", + "acceptable": ["máte čaj?"], + "choices": ["Máte čaj?", "Máš čaj?", "Mám čaj.", "Chcem čaj."], + "vocabCardId": "v4-mate", + "register": "formal" + }, + { + "id": "e4-ot-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the phrase to its meaning.", + "answer": "", + "pairs": [["Chcem", "I want"], ["Dám si", "I'll have"], ["Máte?", "Do you have?"], ["účet", "the bill"], ["zaplatím", "I'll pay"]], + "register": "neutral" + }, + { + "id": "e4-ot-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "A coffee, please.", + "answer": "Kávu, prosím.", + "acceptable": ["kávu, prosím.", "kávu prosím", "kávu, prosím"], + "vocabCardId": "v4-prosim", + "register": "neutral" + } + ] + }, + { + "id": "l4-obj-practice", + "title": "Akuzatív: čo si dáš?", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e4-op-1", + "orderIndex": 0, + "type": "FILL_CASE", + "promptEn": "Dám si ____ (the drink 'káva' in accusative).", + "promptSk": "Dám si ____.", + "answer": "kávu", + "acceptable": ["kávu"], + "choices": ["kávu", "káva", "káve", "kávou"], + "hint": "Feminine -a becomes -u in the accusative.", + "vocabCardId": "v4-kava", + "register": "neutral", + "caseTag": "accusative" + }, + { + "id": "e4-op-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "Chcem ____ (the noun 'voda' in accusative).", + "promptSk": "Chcem ____.", + "answer": "vodu", + "acceptable": ["vodu"], + "choices": ["vodu", "voda", "vode", "vodou"], + "hint": "voda → vodu.", + "vocabCardId": "v4-voda", + "register": "neutral", + "caseTag": "accusative" + }, + { + "id": "e4-op-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "Dám si ____ (the noun 'polievka' in accusative).", + "promptSk": "Dám si ____.", + "answer": "polievku", + "acceptable": ["polievku"], + "choices": ["polievku", "polievka", "polievke", "polievkou"], + "vocabCardId": "v4-polievka", + "register": "neutral", + "caseTag": "accusative" + }, + { + "id": "e4-op-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build: 'Do you have beer?' (formal)", + "answer": "Máte pivo?", + "acceptable": ["máte pivo?", "máte pivo"], + "choices": ["Máte", "pivo?", "Máš", "chcem"], + "register": "formal" + }, + { + "id": "e4-op-5", + "orderIndex": 4, + "type": "DIALOGUE_FILL", + "promptEn": "— Čo si dáte? — ____ si kávu a vodu.", + "promptSk": "— Čo si dáte? — ____ si kávu a vodu.", + "answer": "Dám", + "acceptable": ["dám", "dam"], + "choices": ["Dám", "Dáš", "Máte", "Chce"], + "register": "neutral" + }, + { + "id": "e4-op-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "The bill, please.", + "answer": "Účet, prosím.", + "acceptable": ["účet, prosím.", "účet prosím", "ucet prosim"], + "vocabCardId": "v4-ucet", + "register": "neutral" + }, + { + "id": "e4-op-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Order: 'I'll have a coffee, please.'", + "promptSk": "Dám si kávu, prosím.", + "answer": "Dám si kávu, prosím.", + "acceptable": ["dám si kávu, prosím.", "dám si kávu prosím"], + "audioKey": "dam-si-kavu-prosim", + "register": "neutral" + }, + { + "id": "e4-op-8", + "orderIndex": 7, + "type": "TRANSLATE_SK_EN", + "promptSk": "Zaplatím kartou.", + "answer": "I'll pay by card.", + "acceptable": ["i'll pay by card", "i'll pay by card.", "i will pay by card"], + "vocabCardId": "v4-zaplatim", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit5-v-meste.json b/slovko/app/src/main/assets/curriculum/unit5-v-meste.json new file mode 100644 index 0000000..d137a93 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit5-v-meste.json @@ -0,0 +1,394 @@ +{ + "unit": { "id": "u5", "name": "V meste", "cefr": "A1", "order": 5 }, + "skills": [ + { + "id": "s5-miesta", + "unitId": "u5", + "title": "Miesta a kde je...?", + "description": "Places in town and asking where things are, using the locative case with v and na.", + "iconKey": "city", + "colorKey": "blue", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v5-mesto", "sk": "mesto", "en": "town / city", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Bývam v meste.", "exampleEn": "I live in the city.", "register": "neutral", "frequencyRank": 260 }, + { "id": "v5-obchod", "sk": "obchod", "en": "shop / store", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Idem do obchodu.", "exampleEn": "I'm going to the shop.", "register": "neutral", "frequencyRank": 430 }, + { "id": "v5-namestie", "sk": "námestie", "en": "square", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Kaviareň je na námestí.", "exampleEn": "The café is on the square.", "register": "neutral", "frequencyRank": 650 }, + { "id": "v5-stanica", "sk": "stanica", "en": "station", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Stanica je vľavo.", "exampleEn": "The station is on the left.", "register": "neutral", "frequencyRank": 700 }, + { "id": "v5-kaviaren", "sk": "kaviareň", "en": "café", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Sedíme v kaviarni.", "exampleEn": "We're sitting in the café.", "register": "neutral", "frequencyRank": 760 }, + { "id": "v5-kdeje", "sk": "kde je", "en": "where is", "partOfSpeech": "phrase", "exampleSk": "Kde je stanica?", "exampleEn": "Where is the station?", "register": "neutral", "frequencyRank": 180 }, + { "id": "v5-vlavo", "sk": "vľavo", "en": "on the left", "partOfSpeech": "adverb", "exampleSk": "Zaboč vľavo.", "exampleEn": "Turn left.", "register": "neutral", "frequencyRank": 880 }, + { "id": "v5-vpravo", "sk": "vpravo", "en": "on the right", "partOfSpeech": "adverb", "exampleSk": "Obchod je vpravo.", "exampleEn": "The shop is on the right.", "register": "neutral", "frequencyRank": 890 }, + { "id": "v5-rovno", "sk": "rovno", "en": "straight ahead", "partOfSpeech": "adverb", "exampleSk": "Choď rovno.", "exampleEn": "Go straight.", "register": "neutral", "frequencyRank": 920 } + ], + "lessons": [ + { + "id": "l5-miesta-teach", + "title": "Miesta v meste", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e5-mt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'stanica' mean?", + "answer": "station", + "acceptable": ["station"], + "choices": ["station", "shop", "square", "café"], + "audioKey": "stanica", + "vocabCardId": "v5-stanica", + "register": "neutral" + }, + { + "id": "e5-mt-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the place.", + "answer": "obchod", + "acceptable": ["obchod"], + "choices": ["obchod", "mesto", "stanica", "námestie"], + "audioKey": "obchod", + "vocabCardId": "v5-obchod", + "register": "neutral" + }, + { + "id": "e5-mt-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Kde je kaviareň?", + "answer": "Where is the café?", + "acceptable": ["where is the café?", "where is the cafe?", "where is the café"], + "vocabCardId": "v5-kaviaren", + "register": "neutral" + }, + { + "id": "e5-mt-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the place to its meaning.", + "answer": "", + "pairs": [["mesto", "city"], ["obchod", "shop"], ["stanica", "station"], ["námestie", "square"], ["kaviareň", "café"]], + "register": "neutral" + }, + { + "id": "e5-mt-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which word means 'on the left'?", + "answer": "vľavo", + "acceptable": ["vľavo"], + "choices": ["vľavo", "vpravo", "rovno", "tu"], + "vocabCardId": "v5-vlavo", + "register": "neutral" + }, + { + "id": "e5-mt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "Where is the station?", + "answer": "Kde je stanica?", + "acceptable": ["kde je stanica?", "kde je stanica"], + "vocabCardId": "v5-kdeje", + "register": "neutral" + } + ] + }, + { + "id": "l5-miesta-practice", + "title": "Lokál: v a na", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e5-mp-1", + "orderIndex": 0, + "type": "FILL_CASE", + "promptEn": "Bývam v ____ (the noun 'mesto' in locative).", + "promptSk": "Bývam v ____.", + "answer": "meste", + "acceptable": ["meste"], + "choices": ["meste", "mesto", "mesta", "mestom"], + "hint": "Locative after 'v': mesto → meste.", + "vocabCardId": "v5-mesto", + "register": "neutral", + "caseTag": "locative" + }, + { + "id": "e5-mp-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "Sedíme v ____ (the noun 'kaviareň' in locative).", + "promptSk": "Sedíme v ____.", + "answer": "kaviarni", + "acceptable": ["kaviarni"], + "choices": ["kaviarni", "kaviareň", "kaviarne", "kaviarňou"], + "hint": "kaviareň → kaviarni.", + "vocabCardId": "v5-kaviaren", + "register": "neutral", + "caseTag": "locative" + }, + { + "id": "e5-mp-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "Kaviareň je na ____ (the noun 'námestie' in locative).", + "promptSk": "Kaviareň je na ____.", + "answer": "námestí", + "acceptable": ["námestí"], + "choices": ["námestí", "námestie", "námestia", "námestím"], + "hint": "Use 'na' for open spaces: námestie → námestí.", + "vocabCardId": "v5-namestie", + "register": "neutral", + "caseTag": "locative" + }, + { + "id": "e5-mp-4", + "orderIndex": 3, + "type": "DIALOGUE_FILL", + "promptEn": "— Kde si? — Som ____ stanici.", + "promptSk": "— Kde si? — Som ____ stanici.", + "answer": "na", + "acceptable": ["na"], + "choices": ["na", "v", "do", "z"], + "hint": "Use 'na' with stanica.", + "register": "informal" + }, + { + "id": "e5-mp-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'The shop is on the right.'", + "answer": "Obchod je vpravo.", + "acceptable": ["obchod je vpravo.", "obchod je vpravo"], + "choices": ["Obchod", "je", "vpravo.", "vľavo"], + "register": "neutral" + }, + { + "id": "e5-mp-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "Go straight, then turn left.", + "answer": "Choď rovno, potom zaboč vľavo.", + "acceptable": ["choď rovno, potom zaboč vľavo.", "choď rovno potom zaboč vľavo", "chod rovno, potom zaboc vlavo"], + "vocabCardId": "v5-rovno", + "register": "informal" + }, + { + "id": "e5-mp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Ask: 'Where is the café?'", + "promptSk": "Kde je kaviareň?", + "answer": "Kde je kaviareň?", + "acceptable": ["kde je kaviareň?", "kde je kaviareň"], + "audioKey": "kde-je-kaviaren", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s5-doprava", + "unitId": "u5", + "title": "Doprava: idem autobusom", + "description": "Getting around: transport with the instrumental case (idem autobusom) and going somewhere with idem do / na.", + "iconKey": "travel", + "colorKey": "green", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v5-autobus", "sk": "autobus", "en": "bus", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Idem autobusom.", "exampleEn": "I go by bus.", "register": "neutral", "frequencyRank": 560 }, + { "id": "v5-auto", "sk": "auto", "en": "car", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Idem autom do práce.", "exampleEn": "I go to work by car.", "register": "neutral", "frequencyRank": 300 }, + { "id": "v5-vlak", "sk": "vlak", "en": "train", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Cestujem vlakom.", "exampleEn": "I travel by train.", "register": "neutral", "frequencyRank": 580 }, + { "id": "v5-idem", "sk": "idem", "en": "I go / I'm going", "partOfSpeech": "verb", "exampleSk": "Idem do mesta.", "exampleEn": "I'm going to town.", "register": "neutral", "frequencyRank": 110 }, + { "id": "v5-pesi", "sk": "peši", "en": "on foot", "partOfSpeech": "adverb", "exampleSk": "Idem peši.", "exampleEn": "I'm walking.", "register": "neutral", "frequencyRank": 940 }, + { "id": "v5-zastavka", "sk": "zastávka", "en": "bus stop", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Zastávka je tam.", "exampleEn": "The bus stop is over there.", "register": "neutral", "frequencyRank": 830 } + ], + "lessons": [ + { + "id": "l5-doprava-teach", + "title": "Čím ideš?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e5-dt-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Idem autobusom.", + "answer": "I go by bus.", + "acceptable": ["i go by bus", "i go by bus.", "i'm going by bus"], + "vocabCardId": "v5-autobus", + "register": "neutral" + }, + { + "id": "e5-dt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "What does 'vlak' mean?", + "answer": "train", + "acceptable": ["train"], + "choices": ["train", "bus", "car", "tram"], + "audioKey": "vlak", + "vocabCardId": "v5-vlak", + "register": "neutral" + }, + { + "id": "e5-dt-3", + "orderIndex": 2, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: how is the person travelling?", + "answer": "autom", + "acceptable": ["autom"], + "choices": ["autom", "vlakom", "autobusom", "peši"], + "audioKey": "autom", + "register": "neutral" + }, + { + "id": "e5-dt-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the transport word to its meaning.", + "answer": "", + "pairs": [["autobus", "bus"], ["vlak", "train"], ["auto", "car"], ["peši", "on foot"], ["zastávka", "bus stop"]], + "register": "neutral" + }, + { + "id": "e5-dt-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which means 'I'm walking / on foot'?", + "answer": "Idem peši.", + "acceptable": ["idem peši."], + "choices": ["Idem peši.", "Idem autom.", "Idem vlakom.", "Idem domov."], + "vocabCardId": "v5-pesi", + "register": "neutral" + }, + { + "id": "e5-dt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I travel by train.", + "answer": "Cestujem vlakom.", + "acceptable": ["cestujem vlakom.", "cestujem vlakom"], + "vocabCardId": "v5-vlak", + "register": "neutral" + } + ] + }, + { + "id": "l5-doprava-practice", + "title": "Inštrumentál a idem do / na", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e5-dp-1", + "orderIndex": 0, + "type": "FILL_CASE", + "promptEn": "Idem ____ (the noun 'autobus' in instrumental).", + "promptSk": "Idem ____.", + "answer": "autobusom", + "acceptable": ["autobusom"], + "choices": ["autobusom", "autobus", "autobuse", "autobusu"], + "hint": "Instrumental masculine adds -om.", + "vocabCardId": "v5-autobus", + "register": "neutral", + "caseTag": "instrumental" + }, + { + "id": "e5-dp-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "Cestujem ____ (the noun 'vlak' in instrumental).", + "promptSk": "Cestujem ____.", + "answer": "vlakom", + "acceptable": ["vlakom"], + "choices": ["vlakom", "vlak", "vlaku", "vlaky"], + "hint": "vlak → vlakom.", + "vocabCardId": "v5-vlak", + "register": "neutral", + "caseTag": "instrumental" + }, + { + "id": "e5-dp-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "Idem do práce ____ (the noun 'auto' in instrumental).", + "promptSk": "Idem do práce ____.", + "answer": "autom", + "acceptable": ["autom"], + "choices": ["autom", "auto", "aute", "auta"], + "hint": "Neuter auto → autom.", + "vocabCardId": "v5-auto", + "register": "neutral", + "caseTag": "instrumental" + }, + { + "id": "e5-dp-4", + "orderIndex": 3, + "type": "DIALOGUE_FILL", + "promptEn": "— Kam ideš? — Idem ____ obchodu.", + "promptSk": "— Kam ideš? — Idem ____ obchodu.", + "answer": "do", + "acceptable": ["do"], + "choices": ["do", "na", "v", "z"], + "hint": "Use 'do' + genitive for entering a place.", + "register": "informal", + "caseTag": "genitive" + }, + { + "id": "e5-dp-5", + "orderIndex": 4, + "type": "DIALOGUE_FILL", + "promptEn": "— Kam ideš? — Idem ____ stanicu.", + "promptSk": "— Kam ideš? — Idem ____ stanicu.", + "answer": "na", + "acceptable": ["na"], + "choices": ["na", "do", "v", "po"], + "hint": "Use 'na' for the station.", + "register": "informal" + }, + { + "id": "e5-dp-6", + "orderIndex": 5, + "type": "WORD_BANK", + "promptEn": "Build: 'I'm going to town by bus.'", + "answer": "Idem do mesta autobusom.", + "acceptable": ["idem do mesta autobusom.", "idem do mesta autobusom"], + "choices": ["Idem", "do", "mesta", "autobusom.", "na"], + "register": "neutral" + }, + { + "id": "e5-dp-7", + "orderIndex": 6, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm going to the café.", + "answer": "Idem do kaviarne.", + "acceptable": ["idem do kaviarne.", "idem do kaviarne"], + "vocabCardId": "v5-idem", + "register": "neutral" + }, + { + "id": "e5-dp-8", + "orderIndex": 7, + "type": "SPEAK", + "promptEn": "Say: 'I go to work by car.'", + "promptSk": "Idem do práce autom.", + "answer": "Idem do práce autom.", + "acceptable": ["idem do práce autom.", "idem do práce autom"], + "audioKey": "idem-do-prace-autom", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit6-cas-plany.json b/slovko/app/src/main/assets/curriculum/unit6-cas-plany.json new file mode 100644 index 0000000..bff111d --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit6-cas-plany.json @@ -0,0 +1,360 @@ +{ + "unit": { "id": "u6", "name": "Čas a plány", "cefr": "A1", "order": 6 }, + "skills": [ + { + "id": "s6-cas", + "unitId": "u6", + "title": "Koľko je hodín?", + "description": "Telling the time (o koľkej), days of the week and months, and saying dnes / zajtra / cez víkend.", + "iconKey": "clock", + "colorKey": "purple", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v6-hodina", "sk": "hodina", "en": "hour / o'clock", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Je jedna hodina.", "exampleEn": "It's one o'clock.", "register": "neutral", "frequencyRank": 240 }, + { "id": "v6-dnes", "sk": "dnes", "en": "today", "partOfSpeech": "adverb", "exampleSk": "Dnes mám čas.", "exampleEn": "I have time today.", "register": "neutral", "frequencyRank": 130 }, + { "id": "v6-zajtra", "sk": "zajtra", "en": "tomorrow", "partOfSpeech": "adverb", "exampleSk": "Zajtra idem do práce.", "exampleEn": "Tomorrow I'm going to work.", "register": "neutral", "frequencyRank": 190 }, + { "id": "v6-vikend", "sk": "víkend", "en": "weekend", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Cez víkend oddychujem.", "exampleEn": "I relax over the weekend.", "register": "neutral", "frequencyRank": 510 }, + { "id": "v6-pondelok", "sk": "pondelok", "en": "Monday", "partOfSpeech": "noun", "gender": "m", "exampleSk": "V pondelok mám stretnutie.", "exampleEn": "On Monday I have a meeting.", "register": "neutral", "frequencyRank": 620 }, + { "id": "v6-piatok", "sk": "piatok", "en": "Friday", "partOfSpeech": "noun", "gender": "m", "exampleSk": "V piatok ideme von.", "exampleEn": "On Friday we're going out.", "register": "neutral", "frequencyRank": 630 }, + { "id": "v6-rano", "sk": "ráno", "en": "morning", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Ráno pijem kávu.", "exampleEn": "In the morning I drink coffee.", "register": "neutral", "frequencyRank": 350 }, + { "id": "v6-vecer", "sk": "večer", "en": "evening", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Večer pozerám film.", "exampleEn": "In the evening I watch a film.", "register": "neutral", "frequencyRank": 360 } + ], + "lessons": [ + { + "id": "l6-cas-teach", + "title": "O koľkej?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e6-ct-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Koľko je hodín?", + "answer": "What time is it?", + "acceptable": ["what time is it?", "what time is it"], + "vocabCardId": "v6-hodina", + "register": "neutral" + }, + { + "id": "e6-ct-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "How do you say 'It's three o'clock.'?", + "answer": "Sú tri hodiny.", + "acceptable": ["sú tri hodiny."], + "choices": ["Sú tri hodiny.", "Je tri hodina.", "Je tri hodín.", "Sú tri hodina."], + "hint": "2–4 use 'sú' + hodiny.", + "register": "neutral" + }, + { + "id": "e6-ct-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "'At what time?' in Slovak is...", + "answer": "O koľkej?", + "acceptable": ["o koľkej?"], + "choices": ["O koľkej?", "Koľko je?", "Kedy je?", "Aký čas?"], + "register": "neutral" + }, + { + "id": "e6-ct-4", + "orderIndex": 3, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: what time is mentioned?", + "answer": "o piatej", + "acceptable": ["o piatej"], + "choices": ["o piatej", "o tretej", "o jednej", "o ôsmej"], + "audioKey": "o-piatej", + "register": "neutral" + }, + { + "id": "e6-ct-5", + "orderIndex": 4, + "type": "MATCH_PAIRS", + "promptEn": "Match the time word to its meaning.", + "answer": "", + "pairs": [["dnes", "today"], ["zajtra", "tomorrow"], ["ráno", "morning"], ["večer", "evening"], ["víkend", "weekend"]], + "register": "neutral" + }, + { + "id": "e6-ct-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "Tomorrow I have time.", + "answer": "Zajtra mám čas.", + "acceptable": ["zajtra mám čas.", "zajtra mám čas"], + "vocabCardId": "v6-zajtra", + "register": "neutral" + } + ] + }, + { + "id": "l6-cas-practice", + "title": "Dni a čas", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e6-cp-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "Which day is 'piatok'?", + "answer": "Friday", + "acceptable": ["friday"], + "choices": ["Friday", "Monday", "Sunday", "Tuesday"], + "vocabCardId": "v6-piatok", + "register": "neutral" + }, + { + "id": "e6-cp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "— O koľkej sa stretneme? — ____ piatej.", + "promptSk": "— O koľkej sa stretneme? — ____ piatej.", + "answer": "O", + "acceptable": ["o"], + "choices": ["O", "Na", "Do", "Za"], + "hint": "Time uses 'o' + locative.", + "register": "informal" + }, + { + "id": "e6-cp-3", + "orderIndex": 2, + "type": "WORD_BANK", + "promptEn": "Build: 'On Monday I have a meeting.'", + "answer": "V pondelok mám stretnutie.", + "acceptable": ["v pondelok mám stretnutie.", "v pondelok mám stretnutie"], + "choices": ["V", "pondelok", "mám", "stretnutie.", "zajtra"], + "vocabCardId": "v6-pondelok", + "register": "neutral" + }, + { + "id": "e6-cp-4", + "orderIndex": 3, + "type": "TRANSLATE_EN_SK", + "promptEn": "I relax over the weekend.", + "answer": "Cez víkend oddychujem.", + "acceptable": ["cez víkend oddychujem.", "cez víkend oddychujem"], + "vocabCardId": "v6-vikend", + "register": "neutral" + }, + { + "id": "e6-cp-5", + "orderIndex": 4, + "type": "TRANSLATE_SK_EN", + "promptSk": "Ráno pijem kávu a večer čaj.", + "answer": "In the morning I drink coffee and in the evening tea.", + "acceptable": ["in the morning i drink coffee and in the evening tea.", "in the morning i drink coffee and tea in the evening"], + "vocabCardId": "v6-rano", + "register": "neutral" + }, + { + "id": "e6-cp-6", + "orderIndex": 5, + "type": "SPEAK", + "promptEn": "Say: 'Today I have time.'", + "promptSk": "Dnes mám čas.", + "answer": "Dnes mám čas.", + "acceptable": ["dnes mám čas.", "dnes mám čas"], + "audioKey": "dnes-mam-cas", + "vocabCardId": "v6-dnes", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s6-plany", + "unitId": "u6", + "title": "Robíme plány", + "description": "Making plans and an intro to verb aspect: imperfective robiť vs perfective urobiť, practised with ASPECT_CHOICE.", + "iconKey": "calendar", + "colorKey": "teal", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v6-robit", "sk": "robiť", "en": "to do / be doing (imperfective)", "partOfSpeech": "verb", "exampleSk": "Čo robíš večer?", "exampleEn": "What are you doing this evening?", "register": "neutral", "frequencyRank": 90 }, + { "id": "v6-urobit", "sk": "urobiť", "en": "to do / get done (perfective)", "partOfSpeech": "verb", "exampleSk": "Zajtra to urobím.", "exampleEn": "I'll get it done tomorrow.", "register": "neutral", "frequencyRank": 200 }, + { "id": "v6-stretnut", "sk": "stretnúť sa", "en": "to meet (perfective)", "partOfSpeech": "verb", "exampleSk": "Stretneme sa o päť.", "exampleEn": "We'll meet at five.", "register": "neutral", "frequencyRank": 400 }, + { "id": "v6-volny", "sk": "voľný", "en": "free / available", "partOfSpeech": "adjective", "exampleSk": "Si voľný zajtra?", "exampleEn": "Are you free tomorrow?", "register": "informal", "frequencyRank": 480 }, + { "id": "v6-mozno", "sk": "možno", "en": "maybe", "partOfSpeech": "adverb", "exampleSk": "Možno prídem.", "exampleEn": "Maybe I'll come.", "register": "neutral", "frequencyRank": 290 }, + { "id": "v6-plan", "sk": "plán", "en": "plan", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Máš nejaký plán?", "exampleEn": "Do you have any plans?", "register": "neutral", "frequencyRank": 460 } + ], + "lessons": [ + { + "id": "l6-plany-teach", + "title": "Si voľný?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e6-pt-1", + "orderIndex": 0, + "type": "TRANSLATE_SK_EN", + "promptSk": "Čo robíš večer?", + "answer": "What are you doing this evening?", + "acceptable": ["what are you doing this evening?", "what are you doing tonight?", "what are you doing in the evening"], + "vocabCardId": "v6-robit", + "register": "informal" + }, + { + "id": "e6-pt-2", + "orderIndex": 1, + "type": "MCQ", + "promptEn": "How do you ask 'Are you free tomorrow?' (informal)?", + "answer": "Si voľný zajtra?", + "acceptable": ["si voľný zajtra?"], + "choices": ["Si voľný zajtra?", "Ste voľný zajtra?", "Máš voľný zajtra?", "Je voľný zajtra?"], + "vocabCardId": "v6-volny", + "register": "informal" + }, + { + "id": "e6-pt-3", + "orderIndex": 2, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen: what is being suggested?", + "answer": "Stretneme sa o päť.", + "acceptable": ["stretneme sa o päť."], + "choices": ["Stretneme sa o päť.", "Stretneme sa o tri.", "Idem domov.", "Nemám čas."], + "audioKey": "stretneme-sa-o-pat", + "vocabCardId": "v6-stretnut", + "register": "informal" + }, + { + "id": "e6-pt-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the word to its meaning.", + "answer": "", + "pairs": [["voľný", "free"], ["možno", "maybe"], ["plán", "plan"], ["robiť", "to do"], ["stretnúť sa", "to meet"]], + "register": "neutral" + }, + { + "id": "e6-pt-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "Maybe I'll come.", + "answer": "Možno prídem.", + "acceptable": ["možno prídem.", "možno prídem"], + "vocabCardId": "v6-mozno", + "register": "neutral" + }, + { + "id": "e6-pt-6", + "orderIndex": 5, + "type": "DIALOGUE_FILL", + "promptEn": "— Si voľný? — Áno, čo ____ robiť?", + "promptSk": "— Si voľný? — Áno, čo ____ robiť?", + "answer": "chceš", + "acceptable": ["chceš"], + "choices": ["chceš", "chcem", "chce", "chcete"], + "register": "informal" + } + ] + }, + { + "id": "l6-plany-practice", + "title": "Vid: robiť verzus urobiť", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e6-pp-1", + "orderIndex": 0, + "type": "ASPECT_CHOICE", + "promptEn": "Choose the right aspect: 'Every day I do my homework.' (a repeated, ongoing action)", + "promptSk": "Každý deň ____ úlohy.", + "answer": "robím", + "acceptable": ["robím"], + "choices": ["robím", "urobím", "robil", "urobil"], + "hint": "Repeated action = imperfective robiť.", + "vocabCardId": "v6-robit", + "register": "neutral", + "aspectTag": "imperfective" + }, + { + "id": "e6-pp-2", + "orderIndex": 1, + "type": "ASPECT_CHOICE", + "promptEn": "Choose the right aspect: 'I'll get it done tomorrow.' (one completed action)", + "promptSk": "Zajtra to ____.", + "answer": "urobím", + "acceptable": ["urobím"], + "choices": ["urobím", "robím", "robiť", "urobiť"], + "hint": "Completed result = perfective urobiť.", + "vocabCardId": "v6-urobit", + "register": "neutral", + "aspectTag": "perfective" + }, + { + "id": "e6-pp-3", + "orderIndex": 2, + "type": "ASPECT_CHOICE", + "promptEn": "Choose the right aspect: 'What are you doing right now?' (action in progress)", + "promptSk": "Čo práve ____?", + "answer": "robíš", + "acceptable": ["robíš"], + "choices": ["robíš", "urobíš", "urobil", "robil"], + "hint": "In progress = imperfective.", + "vocabCardId": "v6-robit", + "register": "informal", + "aspectTag": "imperfective" + }, + { + "id": "e6-pp-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build: 'We'll meet at five.'", + "answer": "Stretneme sa o päť.", + "acceptable": ["stretneme sa o päť.", "stretneme sa o päť"], + "choices": ["Stretneme", "sa", "o", "päť.", "tri"], + "vocabCardId": "v6-stretnut", + "register": "informal" + }, + { + "id": "e6-pp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "Do you have any plans for the weekend?", + "answer": "Máš nejaké plány na víkend?", + "acceptable": ["máš nejaké plány na víkend?", "máš nejaké plány na víkend", "máš plány na víkend?"], + "vocabCardId": "v6-plan", + "register": "informal" + }, + { + "id": "e6-pp-6", + "orderIndex": 5, + "type": "DIALOGUE_FILL", + "promptEn": "Texting: '____ o 5ke pri stanici?' (Shall we meet at 5 by the station?)", + "promptSk": "____ o 5ke pri stanici?", + "answer": "Stretneme sa", + "acceptable": ["stretneme sa"], + "choices": ["Stretneme sa", "Robíme", "Idem", "Mám"], + "hint": "Casual texting: 'o 5ke' = 'o piatej'.", + "register": "informal" + }, + { + "id": "e6-pp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Ask a friend: 'Are you free this evening?'", + "promptSk": "Si voľný dnes večer?", + "answer": "Si voľný dnes večer?", + "acceptable": ["si voľný dnes večer?", "si voľný dnes večer"], + "audioKey": "si-volny-dnes-vecer", + "vocabCardId": "v6-volny", + "register": "informal" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit7-kazdy-den.json b/slovko/app/src/main/assets/curriculum/unit7-kazdy-den.json new file mode 100644 index 0000000..754fa62 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit7-kazdy-den.json @@ -0,0 +1,380 @@ +{ + "unit": { "id": "u7", "name": "Každý deň", "cefr": "A1", "order": 7 }, + "skills": [ + { + "id": "s7-rutina", + "unitId": "u7", + "title": "Moja rutina", + "description": "Daily routine verbs and reflexives (umývam sa, obliekam sa) plus frequency adverbs like vždy and niekedy.", + "iconKey": "clock", + "colorKey": "blue", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v7-vstavat", "sk": "vstávať", "en": "to get up", "partOfSpeech": "verb", "exampleSk": "Vstávam o siedmej.", "exampleEn": "I get up at seven.", "register": "neutral", "frequencyRank": 520 }, + { "id": "v7-umyvat-sa", "sk": "umývať sa", "en": "to wash (oneself)", "partOfSpeech": "verb", "exampleSk": "Ráno sa umývam.", "exampleEn": "I wash in the morning.", "register": "neutral", "frequencyRank": 1100 }, + { "id": "v7-obliekat-sa", "sk": "obliekať sa", "en": "to get dressed", "partOfSpeech": "verb", "exampleSk": "Rýchlo sa obliekam.", "exampleEn": "I get dressed quickly.", "register": "neutral", "frequencyRank": 1300 }, + { "id": "v7-ranajkovat", "sk": "raňajkovať", "en": "to have breakfast", "partOfSpeech": "verb", "exampleSk": "Raňajkujem doma.", "exampleEn": "I have breakfast at home.", "register": "neutral", "frequencyRank": 1400 }, + { "id": "v7-spat", "sk": "spať", "en": "to sleep", "partOfSpeech": "verb", "exampleSk": "Spím osem hodín.", "exampleEn": "I sleep eight hours.", "register": "neutral", "frequencyRank": 360 }, + { "id": "v7-vzdy", "sk": "vždy", "en": "always", "partOfSpeech": "adverb", "exampleSk": "Vždy raňajkujem.", "exampleEn": "I always have breakfast.", "register": "neutral", "frequencyRank": 240 }, + { "id": "v7-casto", "sk": "často", "en": "often", "partOfSpeech": "adverb", "exampleSk": "Často chodím peši.", "exampleEn": "I often walk.", "register": "neutral", "frequencyRank": 300 }, + { "id": "v7-niekedy", "sk": "niekedy", "en": "sometimes", "partOfSpeech": "adverb", "exampleSk": "Niekedy spím dlho.", "exampleEn": "Sometimes I sleep in.", "register": "neutral", "frequencyRank": 410 }, + { "id": "v7-nikdy", "sk": "nikdy", "en": "never", "partOfSpeech": "adverb", "exampleSk": "Nikdy nevstávam skoro.", "exampleEn": "I never get up early.", "register": "neutral", "frequencyRank": 350 } + ], + "lessons": [ + { + "id": "l7-rutina-teach", + "title": "Ráno a večer", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e7-rt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'vstávať' mean?", + "answer": "to get up", + "acceptable": ["to get up"], + "choices": ["to get up", "to sleep", "to wash", "to eat"], + "audioKey": "vstavat", + "vocabCardId": "v7-vstavat", + "register": "neutral" + }, + { + "id": "e7-rt-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the verb.", + "answer": "spať", + "acceptable": ["spať"], + "choices": ["spať", "vstávať", "raňajkovať", "umývať sa"], + "audioKey": "spat", + "vocabCardId": "v7-spat", + "register": "neutral" + }, + { + "id": "e7-rt-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Ráno sa umývam.", + "answer": "I wash in the morning.", + "acceptable": ["i wash in the morning.", "i wash in the morning", "i wash myself in the morning"], + "vocabCardId": "v7-umyvat-sa", + "register": "neutral" + }, + { + "id": "e7-rt-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the routine verb to its meaning.", + "answer": "", + "pairs": [["vstávať", "to get up"], ["umývať sa", "to wash"], ["obliekať sa", "to get dressed"], ["raňajkovať", "to have breakfast"], ["spať", "to sleep"]], + "register": "neutral" + }, + { + "id": "e7-rt-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which word means 'often'?", + "answer": "často", + "acceptable": ["často"], + "choices": ["často", "vždy", "nikdy", "niekedy"], + "vocabCardId": "v7-casto", + "register": "neutral" + }, + { + "id": "e7-rt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I always have breakfast.", + "answer": "Vždy raňajkujem.", + "acceptable": ["vždy raňajkujem.", "vždy raňajkujem"], + "vocabCardId": "v7-vzdy", + "register": "neutral" + } + ] + }, + { + "id": "l7-rutina-practice", + "title": "Zvratné sloveso: umývam sa", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e7-rp-1", + "orderIndex": 0, + "type": "DIALOGUE_FILL", + "promptEn": "Complete: 'Ráno ____ umývam.' (reflexive particle)", + "promptSk": "Ráno ____ umývam.", + "answer": "sa", + "acceptable": ["sa"], + "choices": ["sa", "si", "ma", "som"], + "hint": "umývať sa uses the reflexive 'sa'.", + "vocabCardId": "v7-umyvat-sa", + "register": "neutral" + }, + { + "id": "e7-rp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "Complete: 'Rýchlo ____ obliekam.'", + "promptSk": "Rýchlo ____ obliekam.", + "answer": "sa", + "acceptable": ["sa"], + "choices": ["sa", "si", "ťa", "ma"], + "hint": "obliekať sa also takes 'sa'.", + "vocabCardId": "v7-obliekat-sa", + "register": "neutral" + }, + { + "id": "e7-rp-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "Which sentence means 'I never get up early'?", + "answer": "Nikdy nevstávam skoro.", + "acceptable": ["nikdy nevstávam skoro."], + "choices": ["Nikdy nevstávam skoro.", "Vždy vstávam skoro.", "Často vstávam skoro.", "Niekedy vstávam skoro."], + "hint": "Slovak uses a double negative: nikdy + ne-.", + "vocabCardId": "v7-nikdy", + "register": "neutral" + }, + { + "id": "e7-rp-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build: 'I wash in the morning.'", + "answer": "Ráno sa umývam.", + "acceptable": ["ráno sa umývam.", "ráno sa umývam"], + "choices": ["Ráno", "sa", "umývam.", "si"], + "register": "neutral" + }, + { + "id": "e7-rp-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "Sometimes I sleep in.", + "answer": "Niekedy spím dlho.", + "acceptable": ["niekedy spím dlho.", "niekedy spím dlho"], + "vocabCardId": "v7-niekedy", + "register": "neutral" + }, + { + "id": "e7-rp-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I get dressed quickly.", + "answer": "Rýchlo sa obliekam.", + "acceptable": ["rýchlo sa obliekam.", "rýchlo sa obliekam", "obliekam sa rýchlo"], + "vocabCardId": "v7-obliekat-sa", + "register": "neutral" + }, + { + "id": "e7-rp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say: 'I always have breakfast at home.'", + "promptSk": "Vždy raňajkujem doma.", + "answer": "Vždy raňajkujem doma.", + "acceptable": ["vždy raňajkujem doma.", "vždy raňajkujem doma"], + "audioKey": "vzdy-ranajkujem-doma", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s7-modaly", + "unitId": "u7", + "title": "Musím, chcem, môžem", + "description": "Modal verbs musieť, chcieť and môcť with the infinitive, plus drilling imperfective vs perfective aspect.", + "iconKey": "work", + "colorKey": "purple", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v7-musim", "sk": "musím", "en": "I must / I have to", "partOfSpeech": "verb", "exampleSk": "Musím ísť do práce.", "exampleEn": "I have to go to work.", "register": "neutral", "frequencyRank": 130 }, + { "id": "v7-chcem", "sk": "chcem", "en": "I want", "partOfSpeech": "verb", "exampleSk": "Chcem spať.", "exampleEn": "I want to sleep.", "register": "neutral", "frequencyRank": 90 }, + { "id": "v7-mozem", "sk": "môžem", "en": "I can / I may", "partOfSpeech": "verb", "exampleSk": "Môžem ti pomôcť.", "exampleEn": "I can help you.", "register": "neutral", "frequencyRank": 100 }, + { "id": "v7-robit", "sk": "robiť", "en": "to do / to make (imperfective)", "partOfSpeech": "verb", "exampleSk": "Každý deň robím to isté.", "exampleEn": "Every day I do the same thing.", "register": "neutral", "frequencyRank": 70 }, + { "id": "v7-urobit", "sk": "urobiť", "en": "to do / to make (perfective)", "partOfSpeech": "verb", "exampleSk": "Dnes to urobím.", "exampleEn": "I'll do it today.", "register": "neutral", "frequencyRank": 200 }, + { "id": "v7-upratovat", "sk": "upratovať", "en": "to tidy up (imperfective)", "partOfSpeech": "verb", "exampleSk": "Cez víkend upratujem.", "exampleEn": "I tidy up at the weekend.", "register": "neutral", "frequencyRank": 1500 }, + { "id": "v7-pomoct", "sk": "pomôcť", "en": "to help (perfective)", "partOfSpeech": "verb", "exampleSk": "Môžem ti pomôcť.", "exampleEn": "I can help you.", "register": "neutral", "frequencyRank": 320 } + ], + "lessons": [ + { + "id": "l7-modaly-teach", + "title": "Modálne slovesá", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e7-mt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'musím' mean?", + "answer": "I must / I have to", + "acceptable": ["i must / i have to"], + "choices": ["I must / I have to", "I want", "I can", "I know"], + "audioKey": "musim", + "vocabCardId": "v7-musim", + "register": "neutral" + }, + { + "id": "e7-mt-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the modal verb.", + "answer": "chcem", + "acceptable": ["chcem"], + "choices": ["chcem", "musím", "môžem", "viem"], + "audioKey": "chcem", + "vocabCardId": "v7-chcem", + "register": "neutral" + }, + { + "id": "e7-mt-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Môžem ti pomôcť.", + "answer": "I can help you.", + "acceptable": ["i can help you.", "i can help you", "i can help you out"], + "vocabCardId": "v7-mozem", + "register": "informal" + }, + { + "id": "e7-mt-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the modal verb to its meaning.", + "answer": "", + "pairs": [["musím", "I must"], ["chcem", "I want"], ["môžem", "I can"], ["pomôcť", "to help"]], + "register": "neutral" + }, + { + "id": "e7-mt-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which sentence means 'I want to sleep'?", + "answer": "Chcem spať.", + "acceptable": ["chcem spať."], + "choices": ["Chcem spať.", "Musím spať.", "Môžem spať.", "Idem spať."], + "hint": "Modal + infinitive: chcem + spať.", + "vocabCardId": "v7-chcem", + "register": "neutral" + }, + { + "id": "e7-mt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I have to go to work.", + "answer": "Musím ísť do práce.", + "acceptable": ["musím ísť do práce.", "musím ísť do práce", "musim ist do prace"], + "vocabCardId": "v7-musim", + "register": "neutral" + } + ] + }, + { + "id": "l7-modaly-practice", + "title": "Vid: robím alebo urobím?", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e7-mp-1", + "orderIndex": 0, + "type": "ASPECT_CHOICE", + "promptEn": "Choose the right aspect: 'Every day I do the same thing.' (a repeated action)", + "promptSk": "Každý deň ____ to isté.", + "answer": "robím", + "acceptable": ["robím"], + "choices": ["robím", "urobím", "robil", "urobil"], + "hint": "Repeated, habitual action = imperfective robiť.", + "vocabCardId": "v7-robit", + "register": "neutral", + "aspectTag": "imperfective" + }, + { + "id": "e7-mp-2", + "orderIndex": 1, + "type": "ASPECT_CHOICE", + "promptEn": "Choose the right aspect: 'I'll do it today.' (one completed action)", + "promptSk": "Dnes to ____.", + "answer": "urobím", + "acceptable": ["urobím"], + "choices": ["urobím", "robím", "robil", "robievam"], + "hint": "A single finished result = perfective urobiť.", + "vocabCardId": "v7-urobit", + "register": "neutral", + "aspectTag": "perfective" + }, + { + "id": "e7-mp-3", + "orderIndex": 2, + "type": "ASPECT_CHOICE", + "promptEn": "Choose the right aspect: 'I tidy up every weekend.' (a regular activity)", + "promptSk": "Každý víkend ____.", + "answer": "upratujem", + "acceptable": ["upratujem"], + "choices": ["upratujem", "upracem", "upratal", "upratať"], + "hint": "Regular activity = imperfective upratovať → upratujem.", + "vocabCardId": "v7-upratovat", + "register": "neutral", + "aspectTag": "imperfective" + }, + { + "id": "e7-mp-4", + "orderIndex": 3, + "type": "ASPECT_CHOICE", + "promptEn": "Choose the right aspect: 'I can help you.' (a single completed act of helping)", + "promptSk": "Môžem ti ____.", + "answer": "pomôcť", + "acceptable": ["pomôcť"], + "choices": ["pomôcť", "pomáhať", "pomáham", "pomôžem"], + "hint": "After môžem use the infinitive; one act = perfective pomôcť.", + "vocabCardId": "v7-pomoct", + "register": "informal", + "aspectTag": "perfective" + }, + { + "id": "e7-mp-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'I have to tidy up today.'", + "answer": "Dnes musím upratovať.", + "acceptable": ["dnes musím upratovať.", "dnes musím upratovať"], + "choices": ["Dnes", "musím", "upratovať.", "chcem"], + "register": "neutral" + }, + { + "id": "e7-mp-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I want to help you.", + "answer": "Chcem ti pomôcť.", + "acceptable": ["chcem ti pomôcť.", "chcem ti pomôcť", "chcem ti pomoct"], + "vocabCardId": "v7-chcem", + "register": "informal" + }, + { + "id": "e7-mp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say: 'I can do it today.'", + "promptSk": "Dnes to môžem urobiť.", + "answer": "Dnes to môžem urobiť.", + "acceptable": ["dnes to môžem urobiť.", "dnes to môžem urobiť"], + "audioKey": "dnes-to-mozem-urobit", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit8-nakupovanie.json b/slovko/app/src/main/assets/curriculum/unit8-nakupovanie.json new file mode 100644 index 0000000..dd19977 --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit8-nakupovanie.json @@ -0,0 +1,392 @@ +{ + "unit": { "id": "u8", "name": "Nakupovanie", "cefr": "A1", "order": 8 }, + "skills": [ + { + "id": "s8-ceny", + "unitId": "u8", + "title": "Koľko to stojí?", + "description": "Asking prices, euros and cents, paying by card, and the genitive of quantity (dve kávy).", + "iconKey": "money", + "colorKey": "green", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v8-kolko", "sk": "koľko to stojí", "en": "how much does it cost", "partOfSpeech": "phrase", "exampleSk": "Koľko to stojí?", "exampleEn": "How much does it cost?", "register": "neutral", "frequencyRank": 220 }, + { "id": "v8-euro", "sk": "euro", "en": "euro", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Stojí to päť eur.", "exampleEn": "It costs five euros.", "register": "neutral", "frequencyRank": 280 }, + { "id": "v8-cent", "sk": "cent", "en": "cent", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Stojí to deväťdesiat centov.", "exampleEn": "It costs ninety cents.", "register": "neutral", "frequencyRank": 700 }, + { "id": "v8-platit", "sk": "platiť", "en": "to pay", "partOfSpeech": "verb", "exampleSk": "Chcem platiť.", "exampleEn": "I'd like to pay.", "register": "neutral", "frequencyRank": 340 }, + { "id": "v8-kartou", "sk": "platiť kartou", "en": "to pay by card", "partOfSpeech": "phrase", "exampleSk": "Môžem platiť kartou?", "exampleEn": "Can I pay by card?", "register": "neutral", "frequencyRank": 760 }, + { "id": "v8-hotovost", "sk": "hotovosť", "en": "cash", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Platím v hotovosti.", "exampleEn": "I'm paying in cash.", "register": "neutral", "frequencyRank": 900 }, + { "id": "v8-kava", "sk": "káva", "en": "coffee", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Dám si dve kávy.", "exampleEn": "I'll have two coffees.", "register": "neutral", "frequencyRank": 250 }, + { "id": "v8-ucet", "sk": "účet", "en": "bill / receipt", "partOfSpeech": "noun", "gender": "m", "exampleSk": "Účet, prosím.", "exampleEn": "The bill, please.", "register": "neutral", "frequencyRank": 820 } + ], + "lessons": [ + { + "id": "l8-ceny-teach", + "title": "Ceny a platenie", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e8-ct-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'Koľko to stojí?' mean?", + "answer": "How much does it cost?", + "acceptable": ["how much does it cost?"], + "choices": ["How much does it cost?", "Where is it?", "What is it?", "Do you have it?"], + "audioKey": "kolko-to-stoji", + "vocabCardId": "v8-kolko", + "register": "neutral" + }, + { + "id": "e8-ct-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the word.", + "answer": "euro", + "acceptable": ["euro"], + "choices": ["euro", "cent", "káva", "účet"], + "audioKey": "euro", + "vocabCardId": "v8-euro", + "register": "neutral" + }, + { + "id": "e8-ct-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Môžem platiť kartou?", + "answer": "Can I pay by card?", + "acceptable": ["can i pay by card?", "can i pay by card", "may i pay by card?"], + "vocabCardId": "v8-kartou", + "register": "neutral" + }, + { + "id": "e8-ct-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the money word to its meaning.", + "answer": "", + "pairs": [["euro", "euro"], ["cent", "cent"], ["hotovosť", "cash"], ["účet", "bill"], ["platiť", "to pay"]], + "register": "neutral" + }, + { + "id": "e8-ct-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which means 'It costs five euros.'?", + "answer": "Stojí to päť eur.", + "acceptable": ["stojí to päť eur."], + "choices": ["Stojí to päť eur.", "Stojí to päť euro.", "Stojí to päť centov.", "Stojí to päť eurá."], + "hint": "After 5 and higher, use the genitive plural: päť eur.", + "vocabCardId": "v8-euro", + "register": "neutral" + }, + { + "id": "e8-ct-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "The bill, please.", + "answer": "Účet, prosím.", + "acceptable": ["účet, prosím.", "účet prosím", "ucet prosim"], + "vocabCardId": "v8-ucet", + "register": "neutral" + } + ] + }, + { + "id": "l8-ceny-practice", + "title": "Genitív množstva: dve kávy", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e8-cp-1", + "orderIndex": 0, + "type": "FILL_CASE", + "promptEn": "Dám si dve ____ (the noun 'káva' after 'dve').", + "promptSk": "Dám si dve ____.", + "answer": "kávy", + "acceptable": ["kávy"], + "choices": ["kávy", "káva", "káv", "kávu"], + "hint": "After 2, 3, 4 use nominative plural: dve kávy.", + "vocabCardId": "v8-kava", + "register": "neutral", + "caseTag": "genitive" + }, + { + "id": "e8-cp-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "Stojí to päť ____ (the noun 'euro' after 'päť').", + "promptSk": "Stojí to päť ____.", + "answer": "eur", + "acceptable": ["eur"], + "choices": ["eur", "euro", "eurá", "eurom"], + "hint": "After 5+ use the genitive plural: päť eur.", + "vocabCardId": "v8-euro", + "register": "neutral", + "caseTag": "genitive" + }, + { + "id": "e8-cp-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "Stojí to deväťdesiat ____ (the noun 'cent' after 'deväťdesiat').", + "promptSk": "Stojí to deväťdesiat ____.", + "answer": "centov", + "acceptable": ["centov"], + "choices": ["centov", "cent", "centy", "centu"], + "hint": "After numbers 5+ use the genitive plural: centov.", + "vocabCardId": "v8-cent", + "register": "neutral", + "caseTag": "genitive" + }, + { + "id": "e8-cp-4", + "orderIndex": 3, + "type": "FILL_CASE", + "promptEn": "Dám si tri ____ (the noun 'euro' after 'tri').", + "promptSk": "Dám si tri ____.", + "answer": "eurá", + "acceptable": ["eurá"], + "choices": ["eurá", "eur", "euro", "eurách"], + "hint": "After 2, 3, 4 use nominative plural: tri eurá.", + "vocabCardId": "v8-euro", + "register": "neutral", + "caseTag": "genitive" + }, + { + "id": "e8-cp-5", + "orderIndex": 4, + "type": "DIALOGUE_FILL", + "promptEn": "— Ako budete platiť? — ____, prosím.", + "promptSk": "— Ako budete platiť? — ____, prosím.", + "answer": "Kartou", + "acceptable": ["kartou"], + "choices": ["Kartou", "Karta", "Karte", "Kartu"], + "hint": "By card = instrumental: kartou.", + "vocabCardId": "v8-kartou", + "register": "formal", + "caseTag": "instrumental" + }, + { + "id": "e8-cp-6", + "orderIndex": 5, + "type": "WORD_BANK", + "promptEn": "Build: 'I'll have two coffees.'", + "answer": "Dám si dve kávy.", + "acceptable": ["dám si dve kávy.", "dám si dve kávy"], + "choices": ["Dám", "si", "dve", "kávy.", "káva"], + "register": "neutral" + }, + { + "id": "e8-cp-7", + "orderIndex": 6, + "type": "TRANSLATE_EN_SK", + "promptEn": "How much does it cost?", + "answer": "Koľko to stojí?", + "acceptable": ["koľko to stojí?", "koľko to stojí", "kolko to stoji"], + "vocabCardId": "v8-kolko", + "register": "neutral" + }, + { + "id": "e8-cp-8", + "orderIndex": 7, + "type": "SPEAK", + "promptEn": "Ask: 'Can I pay by card?'", + "promptSk": "Môžem platiť kartou?", + "answer": "Môžem platiť kartou?", + "acceptable": ["môžem platiť kartou?", "môžem platiť kartou"], + "audioKey": "mozem-platit-kartou", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s8-oblecenie", + "unitId": "u8", + "title": "Oblečenie a veľkosti", + "description": "Clothing items, sizes, and shopping phrases for trying things on and choosing colours.", + "iconKey": "shop", + "colorKey": "orange", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v8-tricko", "sk": "tričko", "en": "T-shirt", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Hľadám tričko.", "exampleEn": "I'm looking for a T-shirt.", "register": "neutral", "frequencyRank": 1200 }, + { "id": "v8-nohavice", "sk": "nohavice", "en": "trousers", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Tie nohavice sú pekné.", "exampleEn": "Those trousers are nice.", "register": "neutral", "frequencyRank": 1300 }, + { "id": "v8-topanky", "sk": "topánky", "en": "shoes", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Potrebujem nové topánky.", "exampleEn": "I need new shoes.", "register": "neutral", "frequencyRank": 1000 }, + { "id": "v8-velkost", "sk": "veľkosť", "en": "size", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Akú máte veľkosť?", "exampleEn": "What size are you?", "register": "neutral", "frequencyRank": 1100 }, + { "id": "v8-skusit", "sk": "skúsiť si", "en": "to try on", "partOfSpeech": "verb", "exampleSk": "Môžem si to skúsiť?", "exampleEn": "Can I try it on?", "register": "neutral", "frequencyRank": 1400 }, + { "id": "v8-velky", "sk": "veľký", "en": "big", "partOfSpeech": "adjective", "exampleSk": "Je to príliš veľké.", "exampleEn": "It's too big.", "register": "neutral", "frequencyRank": 150 }, + { "id": "v8-maly", "sk": "malý", "en": "small", "partOfSpeech": "adjective", "exampleSk": "Je to trochu malé.", "exampleEn": "It's a bit small.", "register": "neutral", "frequencyRank": 160 } + ], + "lessons": [ + { + "id": "l8-oblecenie-teach", + "title": "Čo si oblečiem?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e8-ot-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'topánky' mean?", + "answer": "shoes", + "acceptable": ["shoes"], + "choices": ["shoes", "trousers", "T-shirt", "size"], + "audioKey": "topanky", + "vocabCardId": "v8-topanky", + "register": "neutral" + }, + { + "id": "e8-ot-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the clothing item.", + "answer": "tričko", + "acceptable": ["tričko"], + "choices": ["tričko", "nohavice", "topánky", "veľkosť"], + "audioKey": "tricko", + "vocabCardId": "v8-tricko", + "register": "neutral" + }, + { + "id": "e8-ot-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Akú máte veľkosť?", + "answer": "What size are you?", + "acceptable": ["what size are you?", "what size are you", "what is your size?"], + "vocabCardId": "v8-velkost", + "register": "formal" + }, + { + "id": "e8-ot-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the item to its meaning.", + "answer": "", + "pairs": [["tričko", "T-shirt"], ["nohavice", "trousers"], ["topánky", "shoes"], ["veľkosť", "size"], ["veľký", "big"]], + "register": "neutral" + }, + { + "id": "e8-ot-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which means 'It's too big.'?", + "answer": "Je to príliš veľké.", + "acceptable": ["je to príliš veľké."], + "choices": ["Je to príliš veľké.", "Je to trochu malé.", "Je to pekné.", "Je to lacné."], + "vocabCardId": "v8-velky", + "register": "neutral" + }, + { + "id": "e8-ot-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I need new shoes.", + "answer": "Potrebujem nové topánky.", + "acceptable": ["potrebujem nové topánky.", "potrebujem nové topánky"], + "vocabCardId": "v8-topanky", + "register": "neutral" + } + ] + }, + { + "id": "l8-oblecenie-practice", + "title": "V obchode: skúsiť si", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e8-op-1", + "orderIndex": 0, + "type": "DIALOGUE_FILL", + "promptEn": "— Dobrý deň, ____ vám pomôcť? — Áno, hľadám tričko.", + "promptSk": "— Dobrý deň, ____ vám pomôcť?", + "answer": "môžem", + "acceptable": ["môžem"], + "choices": ["môžem", "musím", "chcem", "viem"], + "hint": "Can I help you? = Môžem vám pomôcť?", + "vocabCardId": "v8-skusit", + "register": "formal" + }, + { + "id": "e8-op-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "— Môžem si to ____? — Áno, skúšobňa je vzadu.", + "promptSk": "— Môžem si to ____?", + "answer": "skúsiť", + "acceptable": ["skúsiť"], + "choices": ["skúsiť", "skúšam", "skúsim", "skúšať"], + "hint": "After môžem use the infinitive: skúsiť.", + "vocabCardId": "v8-skusit", + "register": "neutral" + }, + { + "id": "e8-op-3", + "orderIndex": 2, + "type": "MCQ", + "promptEn": "The shoes are a bit small. How do you say it?", + "answer": "Sú trochu malé.", + "acceptable": ["sú trochu malé."], + "choices": ["Sú trochu malé.", "Sú príliš veľké.", "Sú veľmi pekné.", "Sú lacné."], + "vocabCardId": "v8-maly", + "register": "neutral" + }, + { + "id": "e8-op-4", + "orderIndex": 3, + "type": "WORD_BANK", + "promptEn": "Build: 'Can I try it on?'", + "answer": "Môžem si to skúsiť?", + "acceptable": ["môžem si to skúsiť?", "môžem si to skúsiť"], + "choices": ["Môžem", "si", "to", "skúsiť?", "skúšam"], + "register": "neutral" + }, + { + "id": "e8-op-5", + "orderIndex": 4, + "type": "TRANSLATE_EN_SK", + "promptEn": "Those trousers are nice.", + "answer": "Tie nohavice sú pekné.", + "acceptable": ["tie nohavice sú pekné.", "tie nohavice sú pekné"], + "vocabCardId": "v8-nohavice", + "register": "neutral" + }, + { + "id": "e8-op-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "It's too big.", + "answer": "Je to príliš veľké.", + "acceptable": ["je to príliš veľké.", "je to príliš veľké", "je to prilis velke"], + "vocabCardId": "v8-velky", + "register": "neutral" + }, + { + "id": "e8-op-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say: 'I'm looking for a T-shirt.'", + "promptSk": "Hľadám tričko.", + "answer": "Hľadám tričko.", + "acceptable": ["hľadám tričko.", "hľadám tričko"], + "audioKey": "hladam-tricko", + "register": "neutral" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/assets/curriculum/unit9-pocasie-pocity.json b/slovko/app/src/main/assets/curriculum/unit9-pocasie-pocity.json new file mode 100644 index 0000000..3950dfe --- /dev/null +++ b/slovko/app/src/main/assets/curriculum/unit9-pocasie-pocity.json @@ -0,0 +1,383 @@ +{ + "unit": { "id": "u9", "name": "Počasie a pocity", "cefr": "A1", "order": 9 }, + "skills": [ + { + "id": "s9-pocasie", + "unitId": "u9", + "title": "Aké je počasie?", + "description": "Talking about the weather and the dative of state (je mi zima) for how you feel physically.", + "iconKey": "weather", + "colorKey": "blue", + "orderIndex": 0, + "cefrLevel": "A1", + "vocab": [ + { "id": "v9-pocasie", "sk": "počasie", "en": "weather", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Aké je dnes počasie?", "exampleEn": "What's the weather like today?", "register": "neutral", "frequencyRank": 480 }, + { "id": "v9-slnko", "sk": "slnko", "en": "sun", "partOfSpeech": "noun", "gender": "n", "exampleSk": "Svieti slnko.", "exampleEn": "The sun is shining.", "register": "neutral", "frequencyRank": 600 }, + { "id": "v9-prsi", "sk": "prší", "en": "it's raining", "partOfSpeech": "verb", "exampleSk": "Vonku prší.", "exampleEn": "It's raining outside.", "register": "neutral", "frequencyRank": 720 }, + { "id": "v9-snezi", "sk": "sneží", "en": "it's snowing", "partOfSpeech": "verb", "exampleSk": "V zime sneží.", "exampleEn": "It snows in winter.", "register": "neutral", "frequencyRank": 980 }, + { "id": "v9-teplo", "sk": "teplo", "en": "warm", "partOfSpeech": "adverb", "exampleSk": "Dnes je teplo.", "exampleEn": "It's warm today.", "register": "neutral", "frequencyRank": 540 }, + { "id": "v9-zima", "sk": "zima", "en": "cold / winter", "partOfSpeech": "noun", "gender": "f", "exampleSk": "Je mi zima.", "exampleEn": "I'm cold.", "register": "neutral", "frequencyRank": 430 }, + { "id": "v9-horuco", "sk": "horúco", "en": "hot", "partOfSpeech": "adverb", "exampleSk": "Je mi horúco.", "exampleEn": "I'm hot.", "register": "neutral", "frequencyRank": 880 }, + { "id": "v9-oblacno", "sk": "oblačno", "en": "cloudy", "partOfSpeech": "adverb", "exampleSk": "Dnes je oblačno.", "exampleEn": "It's cloudy today.", "register": "neutral", "frequencyRank": 1100 } + ], + "lessons": [ + { + "id": "l9-pocasie-teach", + "title": "Počasie vonku", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e9-pt-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'prší' mean?", + "answer": "it's raining", + "acceptable": ["it's raining", "its raining"], + "choices": ["it's raining", "it's snowing", "the sun is shining", "it's warm"], + "audioKey": "prsi", + "vocabCardId": "v9-prsi", + "register": "neutral" + }, + { + "id": "e9-pt-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the weather word.", + "answer": "sneží", + "acceptable": ["sneží"], + "choices": ["sneží", "prší", "slnko", "teplo"], + "audioKey": "snezi", + "vocabCardId": "v9-snezi", + "register": "neutral" + }, + { + "id": "e9-pt-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Aké je dnes počasie?", + "answer": "What's the weather like today?", + "acceptable": ["what's the weather like today?", "what is the weather like today?", "whats the weather like today"], + "vocabCardId": "v9-pocasie", + "register": "neutral" + }, + { + "id": "e9-pt-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the weather word to its meaning.", + "answer": "", + "pairs": [["slnko", "sun"], ["prší", "it's raining"], ["sneží", "it's snowing"], ["teplo", "warm"], ["oblačno", "cloudy"]], + "register": "neutral" + }, + { + "id": "e9-pt-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which means 'The sun is shining.'?", + "answer": "Svieti slnko.", + "acceptable": ["svieti slnko."], + "choices": ["Svieti slnko.", "Prší.", "Sneží.", "Je oblačno."], + "vocabCardId": "v9-slnko", + "register": "neutral" + }, + { + "id": "e9-pt-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "It's warm today.", + "answer": "Dnes je teplo.", + "acceptable": ["dnes je teplo.", "dnes je teplo"], + "vocabCardId": "v9-teplo", + "register": "neutral" + } + ] + }, + { + "id": "l9-pocasie-practice", + "title": "Datív stavu: je mi zima", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e9-pp-1", + "orderIndex": 0, + "type": "FILL_CASE", + "promptEn": "Je ____ zima. (the pronoun 'ja' in dative = to me)", + "promptSk": "Je ____ zima.", + "answer": "mi", + "acceptable": ["mi"], + "choices": ["mi", "ma", "mňa", "som"], + "hint": "Feelings use the dative: je mi zima = I'm cold.", + "vocabCardId": "v9-zima", + "register": "neutral", + "caseTag": "dative" + }, + { + "id": "e9-pp-2", + "orderIndex": 1, + "type": "FILL_CASE", + "promptEn": "Je ____ horúco. (the pronoun 'ja' in dative)", + "promptSk": "Je ____ horúco.", + "answer": "mi", + "acceptable": ["mi"], + "choices": ["mi", "ma", "ja", "mne"], + "hint": "je mi horúco = I'm hot (dative of state).", + "vocabCardId": "v9-horuco", + "register": "neutral", + "caseTag": "dative" + }, + { + "id": "e9-pp-3", + "orderIndex": 2, + "type": "FILL_CASE", + "promptEn": "Je ____ teplo? (the pronoun 'ty' in dative = to you)", + "promptSk": "Je ____ teplo?", + "answer": "ti", + "acceptable": ["ti"], + "choices": ["ti", "ťa", "ty", "tebe"], + "hint": "Are you warm? = Je ti teplo? (dative 'ti').", + "vocabCardId": "v9-teplo", + "register": "informal", + "caseTag": "dative" + }, + { + "id": "e9-pp-4", + "orderIndex": 3, + "type": "DIALOGUE_FILL", + "promptEn": "— Je ti zima? — Áno, je ____ zima.", + "promptSk": "— Je ti zima? — Áno, je ____ zima.", + "answer": "mi", + "acceptable": ["mi"], + "choices": ["mi", "ti", "ma", "som"], + "hint": "Answer about yourself: je mi zima.", + "vocabCardId": "v9-zima", + "register": "informal", + "caseTag": "dative" + }, + { + "id": "e9-pp-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'It's raining outside.'", + "answer": "Vonku prší.", + "acceptable": ["vonku prší.", "vonku prší"], + "choices": ["Vonku", "prší.", "sneží", "je"], + "register": "neutral" + }, + { + "id": "e9-pp-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm cold.", + "answer": "Je mi zima.", + "acceptable": ["je mi zima.", "je mi zima"], + "vocabCardId": "v9-zima", + "register": "neutral" + }, + { + "id": "e9-pp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Say: 'I'm hot.'", + "promptSk": "Je mi horúco.", + "answer": "Je mi horúco.", + "acceptable": ["je mi horúco.", "je mi horúco"], + "audioKey": "je-mi-horuco", + "register": "neutral" + } + ] + } + ] + }, + { + "id": "s9-pocity", + "unitId": "u9", + "title": "Pocity a small talk", + "description": "Emotions, reacting to news, and light small talk with natural everyday reactions.", + "iconKey": "chat", + "colorKey": "pink", + "orderIndex": 1, + "cefrLevel": "A1", + "vocab": [ + { "id": "v9-stastny", "sk": "šťastný", "en": "happy", "partOfSpeech": "adjective", "exampleSk": "Som veľmi šťastný.", "exampleEn": "I'm very happy.", "register": "neutral", "frequencyRank": 700 }, + { "id": "v9-smutny", "sk": "smutný", "en": "sad", "partOfSpeech": "adjective", "exampleSk": "Dnes som smutná.", "exampleEn": "I'm sad today.", "register": "neutral", "frequencyRank": 820 }, + { "id": "v9-unaveny", "sk": "unavený", "en": "tired", "partOfSpeech": "adjective", "exampleSk": "Som veľmi unavený.", "exampleEn": "I'm very tired.", "register": "neutral", "frequencyRank": 760 }, + { "id": "v9-nahnevany", "sk": "nahnevaný", "en": "angry", "partOfSpeech": "adjective", "exampleSk": "Prečo si nahnevaný?", "exampleEn": "Why are you angry?", "register": "neutral", "frequencyRank": 1100 }, + { "id": "v9-skvele", "sk": "skvelé", "en": "great", "partOfSpeech": "adjective", "exampleSk": "To je skvelé!", "exampleEn": "That's great!", "register": "informal", "frequencyRank": 400 }, + { "id": "v9-skoda", "sk": "škoda", "en": "what a pity", "partOfSpeech": "phrase", "exampleSk": "To je škoda.", "exampleEn": "That's a pity.", "register": "informal", "frequencyRank": 520 }, + { "id": "v9-ako-sa-mas", "sk": "ako sa máš", "en": "how are you", "partOfSpeech": "phrase", "exampleSk": "Ahoj, ako sa máš?", "exampleEn": "Hi, how are you?", "register": "informal", "frequencyRank": 60 }, + { "id": "v9-mam-sa-dobre", "sk": "mám sa dobre", "en": "I'm doing well", "partOfSpeech": "phrase", "exampleSk": "Ďakujem, mám sa dobre.", "exampleEn": "Thanks, I'm doing well.", "register": "informal", "frequencyRank": 110 } + ], + "lessons": [ + { + "id": "l9-pocity-teach", + "title": "Ako sa cítiš?", + "orderIndex": 0, + "type": "teach", + "xpReward": 15, + "exercises": [ + { + "id": "e9-ft-1", + "orderIndex": 0, + "type": "MCQ", + "promptEn": "What does 'unavený' mean?", + "answer": "tired", + "acceptable": ["tired"], + "choices": ["tired", "happy", "sad", "angry"], + "audioKey": "unaveny", + "vocabCardId": "v9-unaveny", + "register": "neutral" + }, + { + "id": "e9-ft-2", + "orderIndex": 1, + "type": "LISTEN_CHOOSE", + "promptEn": "Listen and choose the emotion.", + "answer": "šťastný", + "acceptable": ["šťastný"], + "choices": ["šťastný", "smutný", "unavený", "nahnevaný"], + "audioKey": "stastny", + "vocabCardId": "v9-stastny", + "register": "neutral" + }, + { + "id": "e9-ft-3", + "orderIndex": 2, + "type": "TRANSLATE_SK_EN", + "promptSk": "Ahoj, ako sa máš?", + "answer": "Hi, how are you?", + "acceptable": ["hi, how are you?", "hi how are you", "hello, how are you?"], + "vocabCardId": "v9-ako-sa-mas", + "register": "informal" + }, + { + "id": "e9-ft-4", + "orderIndex": 3, + "type": "MATCH_PAIRS", + "promptEn": "Match the feeling to its meaning.", + "answer": "", + "pairs": [["šťastný", "happy"], ["smutný", "sad"], ["unavený", "tired"], ["nahnevaný", "angry"], ["skvelé", "great"]], + "register": "neutral" + }, + { + "id": "e9-ft-5", + "orderIndex": 4, + "type": "MCQ", + "promptEn": "Which reply means 'Thanks, I'm doing well.'?", + "answer": "Ďakujem, mám sa dobre.", + "acceptable": ["ďakujem, mám sa dobre."], + "choices": ["Ďakujem, mám sa dobre.", "Prepáč, nerozumiem.", "Nie, ďakujem.", "Ako sa voláš?"], + "vocabCardId": "v9-mam-sa-dobre", + "register": "informal" + }, + { + "id": "e9-ft-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm very tired.", + "answer": "Som veľmi unavený.", + "acceptable": ["som veľmi unavený.", "som veľmi unavený", "som veľmi unavená"], + "vocabCardId": "v9-unaveny", + "register": "neutral" + } + ] + }, + { + "id": "l9-pocity-practice", + "title": "Reakcie a small talk", + "orderIndex": 1, + "type": "practice", + "xpReward": 15, + "exercises": [ + { + "id": "e9-fp-1", + "orderIndex": 0, + "type": "DIALOGUE_FILL", + "promptEn": "— Ahoj, ako sa ____? — Mám sa dobre, ďakujem.", + "promptSk": "— Ahoj, ako sa ____?", + "answer": "máš", + "acceptable": ["máš"], + "choices": ["máš", "voláš", "máte", "cítiš"], + "hint": "How are you? (informal) = Ako sa máš?", + "vocabCardId": "v9-ako-sa-mas", + "register": "informal" + }, + { + "id": "e9-fp-2", + "orderIndex": 1, + "type": "DIALOGUE_FILL", + "promptEn": "— Dostal som novú prácu! — To je ____!", + "promptSk": "— Dostal som novú prácu! — To je ____!", + "answer": "skvelé", + "acceptable": ["skvelé"], + "choices": ["skvelé", "škoda", "smutné", "zima"], + "hint": "React to good news: To je skvelé!", + "vocabCardId": "v9-skvele", + "register": "informal" + }, + { + "id": "e9-fp-3", + "orderIndex": 2, + "type": "DIALOGUE_FILL", + "promptEn": "— Dnes nemôžem prísť. — To je ____.", + "promptSk": "— Dnes nemôžem prísť. — To je ____.", + "answer": "škoda", + "acceptable": ["škoda"], + "choices": ["škoda", "skvelé", "dobre", "fajn"], + "hint": "React to bad news: To je škoda.", + "vocabCardId": "v9-skoda", + "register": "informal" + }, + { + "id": "e9-fp-4", + "orderIndex": 3, + "type": "DIALOGUE_FILL", + "promptEn": "— Prečo si ____? — Lebo som unavený.", + "promptSk": "— Prečo si ____? — Lebo som unavený.", + "answer": "nahnevaný", + "acceptable": ["nahnevaný"], + "choices": ["nahnevaný", "šťastný", "skvelý", "teplý"], + "hint": "Why are you angry? = Prečo si nahnevaný?", + "vocabCardId": "v9-nahnevany", + "register": "informal" + }, + { + "id": "e9-fp-5", + "orderIndex": 4, + "type": "WORD_BANK", + "promptEn": "Build: 'Thanks, I'm doing well.'", + "answer": "Ďakujem, mám sa dobre.", + "acceptable": ["ďakujem, mám sa dobre.", "ďakujem, mám sa dobre", "ďakujem mám sa dobre"], + "choices": ["Ďakujem,", "mám", "sa", "dobre.", "máš"], + "register": "informal" + }, + { + "id": "e9-fp-6", + "orderIndex": 5, + "type": "TRANSLATE_EN_SK", + "promptEn": "I'm sad today.", + "answer": "Dnes som smutná.", + "acceptable": ["dnes som smutná.", "dnes som smutná", "dnes som smutný"], + "vocabCardId": "v9-smutny", + "register": "neutral" + }, + { + "id": "e9-fp-7", + "orderIndex": 6, + "type": "SPEAK", + "promptEn": "Ask a friend: 'Hi, how are you?'", + "promptSk": "Ahoj, ako sa máš?", + "answer": "Ahoj, ako sa máš?", + "acceptable": ["ahoj, ako sa máš?", "ahoj, ako sa máš"], + "audioKey": "ahoj-ako-sa-mas", + "register": "informal" + } + ] + } + ] + } + ] +} diff --git a/slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsScreen.kt b/slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsScreen.kt new file mode 100644 index 0000000..a4f5437 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsScreen.kt @@ -0,0 +1,177 @@ +package com.slovko.ui.achievements + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import com.slovko.core.designsystem.IconKeys +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.Achievement + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AchievementsScreen( + onExit: () -> Unit, + viewModel: AchievementsViewModel = hiltViewModel(), +) { + val achievements by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Achievements") }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { innerPadding -> + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(MaterialTheme.spacing.screenEdge), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md), + ) { + items(achievements, key = { it.id }) { achievement -> + BadgeCard(achievement) + } + } + } +} + +@Composable +private fun BadgeCard(achievement: Achievement) { + val unlocked = achievement.unlocked + val gold = MaterialTheme.slovkoColors.gold + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val ringModifier = if (unlocked) { + Modifier.border(BorderStroke(3.dp, gold), CircleShape) + } else { + Modifier + } + Surface( + modifier = Modifier + .size(72.dp) + .then(ringModifier) + .clip(CircleShape), + color = if (unlocked) { + gold.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + shape = CircleShape, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = IconKeys[achievement.iconKey], + style = MaterialTheme.typography.headlineMedium, + modifier = if (unlocked) Modifier else Modifier.alpha(0.4f), + ) + } + } + + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + + Text( + text = achievement.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (unlocked) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + + if (unlocked) { + Text( + text = "Unlocked", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = gold, + textAlign = TextAlign.Center, + ) + } else { + Text( + text = "${achievement.progress}/${achievement.threshold}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + LinearProgressIndicator( + progress = { achievement.fraction }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(CircleShape), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsViewModel.kt new file mode 100644 index 0000000..92a6589 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/achievements/AchievementsViewModel.kt @@ -0,0 +1,25 @@ +package com.slovko.ui.achievements + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.domain.model.Achievement +import com.slovko.domain.repository.GamificationRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class AchievementsViewModel @Inject constructor( + private val gamification: GamificationRepository, +) : ViewModel() { + + val state: StateFlow> = + gamification.observeAchievements() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) +} diff --git a/slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt b/slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..a9cb2f1 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt @@ -0,0 +1,305 @@ +package com.slovko.ui.chat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.ChatMessage +import com.slovko.domain.model.ReplyOption + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + scenarioId: String, + onExit: () -> Unit, + viewModel: ChatViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val listState = rememberLazyListState() + + LaunchedEffect(state.showNaturalPraise) { + if (state.showNaturalPraise) { + snackbarHostState.showSnackbar("Pekne po slovensky! 👌") + viewModel.praiseShown() + } + } + + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) { + listState.animateScrollToItem(state.messages.size - 1) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + state.title.ifBlank { "Konverzácia" }, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding), + ) { + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = MaterialTheme.spacing.screenEdge, + vertical = MaterialTheme.spacing.md, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + items(state.messages, key = { it.id }) { message -> + MessageBubble( + message = message, + onSpeak = { viewModel.speak(message.textSk) }, + ) + } + } + + Composer( + suggested = state.suggestedReplies, + sending = state.sending, + onSendReply = viewModel::send, + onSendText = viewModel::sendText, + ) + } + } +} + +@Composable +private fun MessageBubble( + message: ChatMessage, + onSpeak: () -> Unit, +) { + val isUser = message.role == "user" + val alignment = if (isUser) Alignment.End else Alignment.Start + val container = if (isUser) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainer + } + val contentColor = if (isUser) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + val shape = if (isUser) { + RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 6.dp) + } else { + RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 6.dp, bottomEnd = 20.dp) + } + + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = alignment, + ) { + Surface( + onClick = onSpeak, + shape = shape, + color = container, + contentColor = contentColor, + modifier = Modifier.widthIn(max = 300.dp), + ) { + Text( + message.textSk, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.md, + vertical = MaterialTheme.spacing.sm, + ), + ) + } + if (!isUser && !message.textEnGloss.isNullOrBlank()) { + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + message.textEnGloss!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = MaterialTheme.spacing.xs), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +private fun Composer( + suggested: List, + sending: Boolean, + onSendReply: (ReplyOption) -> Unit, + onSendText: (String) -> Unit, +) { + var draft by remember { mutableStateOf("") } + + Surface( + tonalElevation = 2.dp, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.screenEdge, + vertical = MaterialTheme.spacing.sm, + ), + ) { + if (suggested.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + suggested.forEach { reply -> + SuggestedChip( + reply = reply, + enabled = !sending, + onClick = { onSendReply(reply) }, + ) + } + } + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Napíš po slovensky…") }, + shape = RoundedCornerShape(24.dp), + maxLines = 4, + enabled = !sending, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions( + onSend = { + if (draft.isNotBlank()) { + onSendText(draft) + draft = "" + } + }, + ), + ) + Spacer(Modifier.width(MaterialTheme.spacing.xs)) + IconButton( + onClick = { + if (draft.isNotBlank()) { + onSendText(draft) + draft = "" + } + }, + enabled = !sending && draft.isNotBlank(), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } +} + +@Composable +private fun SuggestedChip( + reply: ReplyOption, + enabled: Boolean, + onClick: () -> Unit, +) { + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.clickable(enabled = enabled, onClick = onClick), + ) { + Column( + Modifier.padding( + horizontal = MaterialTheme.spacing.md, + vertical = MaterialTheme.spacing.xs, + ), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + reply.sk, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + if (reply.natural) { + Spacer(Modifier.width(MaterialTheme.spacing.xxs)) + Text("👌", style = MaterialTheme.typography.labelMedium) + } + } + if (reply.enGloss.isNotBlank()) { + Text( + reply.enGloss, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.75f), + ) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/chat/ChatViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..d46444c --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/chat/ChatViewModel.kt @@ -0,0 +1,98 @@ +package com.slovko.ui.chat + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.data.audio.PronunciationPlayer +import com.slovko.domain.model.ChatMessage +import com.slovko.domain.model.ChatScenario +import com.slovko.domain.model.ReplyOption +import com.slovko.domain.repository.ChatRepository +import com.slovko.domain.repository.ContentRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class ChatUiState( + val title: String = "", + val messages: List = emptyList(), + val suggestedReplies: List = emptyList(), + val sending: Boolean = false, + /** Set when the user picked a reply marked as natural; consume via [praiseShown]. */ + val showNaturalPraise: Boolean = false, +) + +@HiltViewModel +class ChatViewModel @Inject constructor( + private val chat: ChatRepository, + private val content: ContentRepository, + private val player: PronunciationPlayer, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val scenarioId: String = savedStateHandle.get("scenarioId").orEmpty() + + private val _state = MutableStateFlow(ChatUiState()) + val state: StateFlow = _state.asStateFlow() + + private var scenario: ChatScenario? = null + + init { + viewModelScope.launch { + chat.startIfEmpty(scenarioId) + scenario = content.getScenario(scenarioId) + _state.update { it.copy(title = scenario?.title.orEmpty()) } + } + viewModelScope.launch { + chat.observeMessages(scenarioId).collect { messages -> + _state.update { current -> + current.copy( + messages = messages, + suggestedReplies = currentSuggestedReplies(messages), + ) + } + } + } + } + + /** Replies of the latest partner turn, keyed off how many partner messages exist so far. */ + private fun currentSuggestedReplies(messages: List): List { + val turns = scenario?.turns ?: return emptyList() + val partnerCount = messages.count { it.role == "partner" } + val index = partnerCount - 1 + return turns.getOrNull(index)?.replies ?: emptyList() + } + + fun send(reply: ReplyOption) { + sendInternal(reply.sk, reply.enGloss, natural = reply.natural) + } + + fun sendText(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return + sendInternal(trimmed, gloss = null, natural = false) + } + + private fun sendInternal(text: String, gloss: String?, natural: Boolean) { + if (_state.value.sending) return + _state.update { it.copy(sending = true, showNaturalPraise = natural) } + viewModelScope.launch { + chat.sendUserMessage(scenarioId, text, gloss) + chat.requestPartnerReply(scenarioId) + _state.update { it.copy(sending = false) } + } + } + + /** Acknowledge the naturalness snackbar so it is not shown again. */ + fun praiseShown() { + _state.update { it.copy(showNaturalPraise = false) } + } + + fun speak(text: String) { + player.speak(text) + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt b/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt new file mode 100644 index 0000000..5e9447d --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt @@ -0,0 +1,240 @@ +package com.slovko.ui.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.component.MajaMascot +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.Phrase +import com.slovko.domain.model.Register + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PhrasebookScreen( + onExit: () -> Unit, + viewModel: PhrasebookViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Phrasebook") }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + ) + }, + ) { innerPadding -> + Column( + Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + OutlinedTextField( + value = state.query, + onValueChange = viewModel::onQueryChange, + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.screenEdge, + vertical = MaterialTheme.spacing.xs, + ), + singleLine = true, + leadingIcon = { + Icon(Icons.Filled.Search, contentDescription = null) + }, + placeholder = { Text("Search Slovak or English…") }, + ) + + if (state.phrases.isEmpty()) { + EmptyPhrasebook(query = state.query) + } else { + LazyColumn( + contentPadding = PaddingValues( + horizontal = MaterialTheme.spacing.screenEdge, + vertical = MaterialTheme.spacing.sm, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + items(state.phrases, key = { it.id }) { phrase -> + PhraseRow( + phrase = phrase, + onSpeak = { viewModel.speak(phrase.sk) }, + ) + } + } + } + } + } +} + +@Composable +private fun PhraseRow( + phrase: Phrase, + onSpeak: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + phrase.sk, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + phrase.en, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + RegisterChip(register = phrase.register) + phrase.note?.let { + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.width(MaterialTheme.spacing.xs)) + + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onSpeak) { + Icon( + Icons.Filled.VolumeUp, + contentDescription = "Play pronunciation", + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton(onClick = { clipboard.setText(AnnotatedString(phrase.sk)) }) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = "Copy Slovak", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun RegisterChip(register: Register) { + val label = when (register) { + Register.INFORMAL -> "Informal" + Register.NEUTRAL -> "Neutral" + Register.FORMAL -> "Formal" + } + val container = when (register) { + Register.INFORMAL -> MaterialTheme.colorScheme.tertiaryContainer + Register.NEUTRAL -> MaterialTheme.colorScheme.secondaryContainer + Register.FORMAL -> MaterialTheme.colorScheme.primaryContainer + } + val content = when (register) { + Register.INFORMAL -> MaterialTheme.colorScheme.onTertiaryContainer + Register.NEUTRAL -> MaterialTheme.colorScheme.onSecondaryContainer + Register.FORMAL -> MaterialTheme.colorScheme.onPrimaryContainer + } + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = container, + ) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = content, + modifier = Modifier.padding( + horizontal = MaterialTheme.spacing.xs, + vertical = MaterialTheme.spacing.xxs, + ), + ) + } +} + +@Composable +private fun EmptyPhrasebook(query: String) { + Column( + Modifier + .fillMaxSize() + .padding(MaterialTheme.spacing.screenEdge), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MajaMascot(pose = "thinking") + Spacer(Modifier.height(MaterialTheme.spacing.md)) + Text( + if (query.isBlank()) "No phrases yet" else "Nič tu nie je", + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + if (query.isBlank()) { + "Phrases will appear here as your phrasebook grows." + } else { + "No phrases match “$query”. Try another word." + }, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookViewModel.kt new file mode 100644 index 0000000..d1bc191 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookViewModel.kt @@ -0,0 +1,56 @@ +package com.slovko.ui.chat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.data.audio.PronunciationPlayer +import com.slovko.domain.model.Phrase +import com.slovko.domain.repository.ContentRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +data class PhrasebookUiState( + val query: String = "", + val phrases: List = emptyList(), +) + +@HiltViewModel +class PhrasebookViewModel @Inject constructor( + private val content: ContentRepository, + private val player: PronunciationPlayer, +) : ViewModel() { + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + + val state: StateFlow = + combine(content.observePhrases(), _query) { phrases, query -> + val trimmed = query.trim() + val filtered = if (trimmed.isEmpty()) { + phrases + } else { + phrases.filter { + it.sk.contains(trimmed, ignoreCase = true) || + it.en.contains(trimmed, ignoreCase = true) + } + } + PhrasebookUiState(query = query, phrases = filtered) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = PhrasebookUiState(), + ) + + fun onQueryChange(value: String) { + _query.value = value + } + + fun speak(text: String) { + player.speak(text) + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt b/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt new file mode 100644 index 0000000..359554d --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt @@ -0,0 +1,201 @@ +package com.slovko.ui.chathub + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.IconKeys +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.ChatScenario + +@Composable +fun ChatHubScreen( + onOpenChat: (String) -> Unit, + onOpenPhrasebook: () -> Unit, + viewModel: ChatHubViewModel = hiltViewModel(), +) { + val scenarios by viewModel.state.collectAsStateWithLifecycle() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = MaterialTheme.spacing.screenEdge, + end = MaterialTheme.spacing.screenEdge, + top = MaterialTheme.spacing.lg, + bottom = MaterialTheme.spacing.xl, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + item { + Text( + "Píšeme si", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + "Practice real conversations", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(MaterialTheme.spacing.md)) + } + + item { + PhrasebookCard(onClick = onOpenPhrasebook) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + } + + items(scenarios, key = { it.id }) { scenario -> + ScenarioCard( + scenario = scenario, + onClick = { if (!scenario.locked) onOpenChat(scenario.id) }, + ) + } + } +} + +@Composable +private fun PhrasebookCard(onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.lg), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("📒", style = MaterialTheme.typography.displaySmall) + Spacer(Modifier.size(MaterialTheme.spacing.md)) + Column(Modifier.weight(1f)) { + Text( + "Texting phrasebook", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + "Handy lines for every chat", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +private fun ScenarioCard( + scenario: ChatScenario, + onClick: () -> Unit, +) { + val locked = scenario.locked + val baseModifier = Modifier.fillMaxWidth() + val cardModifier = if (locked) baseModifier else baseModifier.clickable { onClick() } + + Card( + modifier = cardModifier, + colors = CardDefaults.cardColors( + containerColor = if (locked) { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + ), + ) { + Row( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + IconKeys[scenario.iconKey], + style = MaterialTheme.typography.headlineMedium, + ) + } + Spacer(Modifier.size(MaterialTheme.spacing.md)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + scenario.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = if (locked) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + if (locked) { + Spacer(Modifier.size(MaterialTheme.spacing.xs)) + Text("🔒", style = MaterialTheme.typography.titleMedium) + } + } + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + scenario.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + CefrChip(scenario.cefrLevel, dimmed = locked) + } + } + } +} + +@Composable +private fun CefrChip(level: String, dimmed: Boolean) { + Surface( + shape = RoundedCornerShape(999.dp), + color = if (dimmed) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.slovkoColors.gold.copy(alpha = 0.20f) + }, + ) { + Text( + level, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubViewModel.kt new file mode 100644 index 0000000..3db6f83 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubViewModel.kt @@ -0,0 +1,25 @@ +package com.slovko.ui.chathub + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.domain.model.ChatScenario +import com.slovko.domain.repository.ContentRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class ChatHubViewModel @Inject constructor( + private val content: ContentRepository, +) : ViewModel() { + + val state: StateFlow> = + content.observeScenarios() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList(), + ) +} diff --git a/slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardScreen.kt b/slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardScreen.kt new file mode 100644 index 0000000..f2ee764 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardScreen.kt @@ -0,0 +1,177 @@ +package com.slovko.ui.leaderboard + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.League +import com.slovko.domain.model.LeagueStanding + +@Composable +fun LeaderboardScreen(viewModel: LeaderboardViewModel = hiltViewModel()) { + val league by viewModel.state.collectAsStateWithLifecycle() + + Box(Modifier.fillMaxSize()) { + when (val l = league) { + null -> Box(Modifier.fillMaxSize(), Alignment.Center) { CircularProgressIndicator() } + else -> LeagueContent(l) + } + } +} + +@Composable +private fun LeagueContent(league: League) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + horizontal = MaterialTheme.spacing.screenEdge, + vertical = MaterialTheme.spacing.lg, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + item { LeagueHeader(league) } + item { Spacer(Modifier.height(MaterialTheme.spacing.md)) } + + items(league.standings, key = { it.rank }) { standing -> + StandingRow( + standing = standing, + promoteCutoff = league.promoteCutoff, + demoteCutoff = league.demoteCutoff, + ) + } + } +} + +@Composable +private fun LeagueHeader(league: League) { + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) { + Text( + "🏆", + style = MaterialTheme.typography.displaySmall, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + "${league.tier.displayName} League", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + "${league.daysRemaining} days left", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + Text( + "Your league is a private practice cohort — race friendly bots.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +@Composable +private fun StandingRow( + standing: LeagueStanding, + promoteCutoff: Int, + demoteCutoff: Int, +) { + val scheme = MaterialTheme.colorScheme + val slovko = MaterialTheme.slovkoColors + + val inPromotionZone = standing.rank <= promoteCutoff + val inRelegationZone = standing.rank > demoteCutoff + + val container = if (standing.isUser) scheme.primaryContainer else scheme.surfaceContainer + val accent = when { + inPromotionZone -> slovko.success + inRelegationZone -> scheme.error + else -> null + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + color = container, + ) { + Row(Modifier.height(64.dp), verticalAlignment = Alignment.CenterVertically) { + // Subtle zone accent on the left edge. + Box( + Modifier + .width(4.dp) + .height(64.dp) + .clip(RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp)) + .then( + if (accent != null) { + Modifier.background(accent.copy(alpha = 0.85f)) + } else { + Modifier + }, + ), + ) + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "#${standing.rank}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = scheme.onSurfaceVariant, + modifier = Modifier.width(40.dp), + ) + Text( + "🦊", + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.width(MaterialTheme.spacing.sm)) + Text( + standing.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (standing.isUser) FontWeight.Bold else FontWeight.Normal, + color = if (standing.isUser) scheme.onPrimaryContainer else scheme.onSurface, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(MaterialTheme.spacing.sm)) + Text( + "${standing.xp} XP", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (standing.isUser) scheme.onPrimaryContainer else scheme.onSurface, + ) + } + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardViewModel.kt new file mode 100644 index 0000000..e545ee4 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/leaderboard/LeaderboardViewModel.kt @@ -0,0 +1,25 @@ +package com.slovko.ui.leaderboard + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.domain.model.League +import com.slovko.domain.repository.GamificationRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LeaderboardViewModel @Inject constructor( + private val gamification: GamificationRepository, +) : ViewModel() { + + val state: StateFlow = gamification.observeLeague() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + init { + viewModelScope.launch { gamification.refreshLeague() } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt new file mode 100644 index 0000000..b6aa48a --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt @@ -0,0 +1,358 @@ +package com.slovko.ui.lesson + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.LocalReducedMotion +import com.slovko.core.designsystem.component.ConfettiOverlay +import com.slovko.core.designsystem.component.MajaMascot +import com.slovko.core.designsystem.component.PrimaryButton +import com.slovko.core.designsystem.component.WordTile +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.Feedback +import com.slovko.ui.lesson.exercise.ExerciseBody + +@Composable +fun LessonScreen( + lessonId: String, + onExit: () -> Unit, + viewModel: LessonViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(lessonId) { viewModel.start(lessonId) } + + Box(Modifier.fillMaxSize()) { + when (val s = state) { + is LessonUiState.Loading -> LoadingState() + is LessonUiState.Error -> ErrorState(onExit) + is LessonUiState.Active -> ActiveState(s, viewModel, onExit) + is LessonUiState.Completed -> CompletedState(s, onExit) + } + } +} + +@Composable +private fun LoadingState() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorState(onExit: () -> Unit) { + Column( + Modifier.fillMaxSize().padding(MaterialTheme.spacing.screenEdge), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MajaMascot(pose = "worried") + Spacer(Modifier.height(MaterialTheme.spacing.md)) + Text("Túto lekciu sa nepodarilo načítať", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + "Skús to prosím znova o chvíľu.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + PrimaryButton(text = "Späť", onClick = onExit) + } +} + +@Composable +private fun ActiveState( + s: LessonUiState.Active, + viewModel: LessonViewModel, + onExit: () -> Unit, +) { + Column(Modifier.fillMaxSize()) { + // Top bar: close + progress. + Row( + Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.spacing.sm, + vertical = MaterialTheme.spacing.xs, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + IconButton(onClick = onExit) { + Icon(Icons.Filled.Close, contentDescription = "Zatvoriť") + } + LinearProgressIndicator( + progress = { s.progress }, + modifier = Modifier + .weight(1f) + .height(10.dp) + .clip(RoundedCornerShape(8.dp)), + ) + } + + // Body — scrollable so long exercises and feedback never clip. + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = MaterialTheme.spacing.screenEdge) + .padding(top = MaterialTheme.spacing.lg), + ) { + ExerciseBody( + exercise = s.exercise, + selected = s.selected, + onSelect = viewModel::select, + typed = s.typed, + onTyped = viewModel::setTyped, + revealedCorrect = s.feedback != null, + onSpeak = viewModel::speak, + ) + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + } + + // Bottom action zone — feedback banner + primary button. + Column( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.screenEdge), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md), + ) { + AnimatedVisibility(visible = s.feedback != null) { + s.feedback?.let { FeedbackBanner(it, viewModel) } + } + + val canCheck = s.selected != null || s.typed.isNotBlank() + PrimaryButton( + text = if (s.feedback != null) "Pokračovať" else "Skontrolovať", + onClick = { + if (s.feedback != null) viewModel.continueNext() else viewModel.check() + }, + enabled = s.feedback != null || canCheck, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun FeedbackBanner(feedback: Feedback, viewModel: LessonViewModel) { + val container = if (feedback.correct) { + MaterialTheme.slovkoColors.success.copy(alpha = 0.16f) + } else { + MaterialTheme.colorScheme.errorContainer + } + val accent = if (feedback.correct) { + MaterialTheme.slovkoColors.success + } else { + MaterialTheme.colorScheme.error + } + + Surface( + color = container, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(MaterialTheme.spacing.md)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + if (feedback.correct) "🎉" else "💡", + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.size(MaterialTheme.spacing.xs)) + Text( + feedback.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = accent, + ) + } + if (!feedback.correct) { + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + "Správna odpoveď:", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + WordTile( + text = feedback.correctAnswer, + onSpeak = { viewModel.speak(feedback.correctAnswer) }, + ) + feedback.explanation?.let { + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun CompletedState(s: LessonUiState.Completed, onExit: () -> Unit) { + val reduced = LocalReducedMotion.current + + val targetXp = s.xpEarned + val targetAcc = (s.accuracy * 100f).toInt() + val targetSec = (s.durationMs / 1000L).toInt() + + val xp by animateIntAsState( + targetValue = targetXp, + animationSpec = tween(if (reduced) 0 else 900, easing = LinearEasing), + label = "xp", + ) + val acc by animateIntAsState( + targetValue = targetAcc, + animationSpec = tween(if (reduced) 0 else 900, easing = LinearEasing), + label = "acc", + ) + val secs by animateIntAsState( + targetValue = targetSec, + animationSpec = tween(if (reduced) 0 else 900, easing = LinearEasing), + label = "secs", + ) + + Box(Modifier.fillMaxSize()) { + ConfettiOverlay(show = true, modifier = Modifier.align(Alignment.TopCenter)) + + Column( + Modifier + .fillMaxSize() + .padding(MaterialTheme.spacing.screenEdge), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MajaMascot(pose = "celebrating", size = 120) + Spacer(Modifier.height(MaterialTheme.spacing.md)) + Text( + "Lekcia hotová!", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + "Skvelá práca — len tak ďalej.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(MaterialTheme.spacing.xl)) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + StatCard( + label = "XP", + value = "+$xp", + accent = MaterialTheme.slovkoColors.gold, + modifier = Modifier.weight(1f), + ) + StatCard( + label = "Presnosť", + value = "$acc%", + accent = MaterialTheme.slovkoColors.success, + modifier = Modifier.weight(1f), + ) + StatCard( + label = "Čas", + value = formatDuration(secs), + accent = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f), + ) + } + + Spacer(Modifier.height(MaterialTheme.spacing.xl)) + + PrimaryButton( + text = "Pokračovať", + onClick = onExit, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun StatCard( + label: String, + value: String, + accent: Color, + modifier: Modifier = Modifier, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(20.dp), + modifier = modifier, + ) { + Column( + Modifier + .fillMaxWidth() + .padding(vertical = MaterialTheme.spacing.md, horizontal = MaterialTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = accent, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun formatDuration(totalSeconds: Int): String { + val m = totalSeconds / 60 + val s = totalSeconds % 60 + return if (m > 0) "${m}m ${s}s" else "${s}s" +} diff --git a/slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt new file mode 100644 index 0000000..de16377 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt @@ -0,0 +1,247 @@ +package com.slovko.ui.lesson + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.data.audio.PronunciationPlayer +import com.slovko.domain.GamificationConfig +import com.slovko.domain.model.CardDirection +import com.slovko.domain.model.Exercise +import com.slovko.domain.model.ExerciseType +import com.slovko.domain.model.Feedback +import com.slovko.domain.repository.ContentRepository +import com.slovko.domain.repository.GamificationRepository +import com.slovko.domain.repository.ProgressRepository +import com.slovko.domain.repository.SettingsRepository +import com.slovko.domain.repository.SrsRepository +import com.slovko.domain.usecase.GradeAnswerUseCase +import com.slovko.domain.usecase.XpCalculator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface LessonUiState { + data object Loading : LessonUiState + data object Error : LessonUiState + + data class Active( + val exercise: Exercise, + /** answered / total — used as a progress fraction in [0,1]. */ + val progress: Float, + val answered: Int, + val total: Int, + val selected: String?, + val typed: String, + val feedback: Feedback?, + ) : LessonUiState + + data class Completed( + val xpEarned: Int, + val accuracy: Float, + val durationMs: Long, + ) : LessonUiState +} + +@HiltViewModel +class LessonViewModel @Inject constructor( + private val content: ContentRepository, + private val srs: SrsRepository, + private val progress: ProgressRepository, + private val gamification: GamificationRepository, + private val settings: SettingsRepository, + private val gradeAnswer: GradeAnswerUseCase, + private val player: PronunciationPlayer, +) : ViewModel() { + + private val _state = MutableStateFlow(LessonUiState.Loading) + val state: StateFlow = _state.asStateFlow() + + private var lessonId: String = "" + + /** Mutable working queue of exercises still to clear. */ + private val queue = ArrayDeque() + + /** Total distinct exercises that must be cleared once (denominator for progress). */ + private var totalToClear = 0 + + /** How many distinct exercises have been cleared correctly. */ + private var cleared = 0 + + /** Per-exercise (type, correct) in answer order — feeds XpCalculator. */ + private val results = mutableListOf>() + + /** Exercises answered correctly (carry vocabCardId for SRS births). */ + private val correctlyAnswered = mutableListOf() + + /** How many times each exercise id has been re-queued after a miss. */ + private val requeueCount = mutableMapOf() + + /** Ids that have already been counted toward [cleared] (avoid double counting). */ + private val clearedIds = mutableSetOf() + + private var newWords = 0 + private var startMs = 0L + private var loaded = false + + fun start(id: String) { + if (loaded && id == lessonId) return + lessonId = id + loaded = true + load() + } + + private fun load() { + viewModelScope.launch { + val lesson = content.getLesson(lessonId) + val exercises = lesson?.exercises.orEmpty().sortedBy { it.orderIndex } + if (exercises.isEmpty()) { + _state.value = LessonUiState.Error + return@launch + } + queue.clear() + queue.addAll(exercises) + totalToClear = exercises.size + cleared = 0 + results.clear() + correctlyAnswered.clear() + requeueCount.clear() + clearedIds.clear() + newWords = 0 + startMs = System.currentTimeMillis() + emitActive(selected = null, typed = "", feedback = null) + } + } + + private fun emitActive(selected: String?, typed: String, feedback: Feedback?) { + val ex = queue.firstOrNull() ?: return + _state.value = LessonUiState.Active( + exercise = ex, + progress = if (totalToClear <= 0) 0f else cleared.toFloat() / totalToClear, + answered = cleared, + total = totalToClear, + selected = selected, + typed = typed, + feedback = feedback, + ) + } + + fun select(value: String) { + val s = _state.value as? LessonUiState.Active ?: return + if (s.feedback != null) return + _state.value = s.copy(selected = value) + } + + fun setTyped(value: String) { + val s = _state.value as? LessonUiState.Active ?: return + if (s.feedback != null) return + _state.value = s.copy(typed = value) + } + + fun speak(text: String) { + player.speak(text) + } + + fun check() { + val s = _state.value as? LessonUiState.Active ?: return + if (s.feedback != null) return + val ex = s.exercise + val answer = when (ex.type) { + ExerciseType.LISTEN_TYPE, ExerciseType.TRANSLATE_EN_SK, + ExerciseType.TRANSLATE_SK_EN, ExerciseType.WORD_BANK, + ExerciseType.DIALOGUE_FILL, ExerciseType.FILL_CASE -> s.typed + else -> s.selected.orEmpty() + } + if (answer.isBlank()) return + + val result = gradeAnswer(ex, answer) + results.add(ex.type to result.correct) + + if (result.correct) { + if (clearedIds.add(ex.id)) cleared++ + if (correctlyAnswered.none { it.id == ex.id }) { + correctlyAnswered.add(ex) + if (ex.vocabCardId != null) newWords++ + } + _state.value = s.copy( + feedback = Feedback( + correct = true, + title = "Správne!", + correctAnswer = ex.answer, + ), + ) + } else { + _state.value = s.copy( + feedback = Feedback( + correct = false, + title = "Skoro", + correctAnswer = ex.answer, + explanation = ex.hint, + ), + ) + } + } + + fun continueNext() { + val s = _state.value as? LessonUiState.Active ?: return + val feedback = s.feedback ?: return + val ex = queue.removeFirstOrNull() ?: return + + if (!feedback.correct) { + val seen = requeueCount.getOrDefault(ex.id, 0) + if (seen < GamificationConfig.Mistake.MAX_REQUEUE) { + requeueCount[ex.id] = seen + 1 + val insertAt = GamificationConfig.Mistake.REQUEUE_AFTER_N_ITEMS + .coerceAtMost(queue.size) + queue.add(insertAt, ex) + } + } + + if (queue.isEmpty()) { + finish() + } else { + emitActive(selected = null, typed = "", feedback = null) + } + } + + private fun finish() { + viewModelScope.launch { + val durationMs = System.currentTimeMillis() - startMs + val total = results.size + val correctCount = results.count { it.second } + val accuracy = if (total <= 0) 1f else correctCount.toFloat() / total + val allCorrect = total > 0 && correctCount == total + val firstOfDay = !progress.hasPracticedToday() + val challenge = settings.current().challengeMode + + val breakdown = XpCalculator.lessonXp( + results = results, + fast = durationMs < 90_000, + firstOfDay = firstOfDay, + challenge = challenge, + ) + + progress.recordLessonCompletion( + lessonId = lessonId, + xpEarned = breakdown.total, + accuracy = accuracy, + durationMs = durationMs, + newWords = newWords, + ) + + for (ex in correctlyAnswered) { + ex.vocabCardId?.let { srs.bornCard(it, CardDirection.EN_TO_SK) } + } + + gamification.progressQuests(xp = breakdown.total, lessons = 1, perfect = allCorrect) + gamification.evaluateAchievements() + + _state.value = LessonUiState.Completed( + xpEarned = breakdown.total, + accuracy = accuracy, + durationMs = durationMs, + ) + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt b/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt new file mode 100644 index 0000000..8ce56d9 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt @@ -0,0 +1,275 @@ +package com.slovko.ui.lesson.exercise + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import com.slovko.core.designsystem.component.OptionChip +import com.slovko.core.designsystem.component.OptionState +import com.slovko.core.designsystem.component.WordTile +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.Exercise +import com.slovko.domain.model.ExerciseType + +/** + * Renders the body of a single exercise according to its [Exercise.type]. + * Pure presentation — all state lives in the caller (LessonViewModel). + */ +@Composable +fun ExerciseBody( + exercise: Exercise, + selected: String?, + onSelect: (String) -> Unit, + typed: String, + onTyped: (String) -> Unit, + revealedCorrect: Boolean, + onSpeak: (String) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + PromptHeader(exercise, onSpeak) + Spacer(Modifier.height(MaterialTheme.spacing.lg)) + + when (exercise.type) { + ExerciseType.MCQ, + ExerciseType.TRANSLATE_SK_EN, + ExerciseType.LISTEN_CHOOSE, + ExerciseType.ASPECT_CHOICE, + -> ChoiceBody(exercise, selected, onSelect, revealedCorrect) + + ExerciseType.WORD_BANK -> WordBankBody(exercise, typed, onTyped, onSelect) + + ExerciseType.TRANSLATE_EN_SK, + ExerciseType.LISTEN_TYPE, + ExerciseType.FILL_CASE, + ExerciseType.DIALOGUE_FILL, + -> TypeBody(exercise, typed, onTyped) + + ExerciseType.SPEAK -> SpeakBody(exercise, selected, onSelect, onSpeak) + + ExerciseType.MATCH_PAIRS -> ChoiceBody(exercise, selected, onSelect, revealedCorrect) + } + } +} + +@Composable +private fun PromptHeader(exercise: Exercise, onSpeak: (String) -> Unit) { + val instruction = instructionFor(exercise.type) + val hero = exercise.promptSk ?: exercise.promptEn + + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + instruction, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + + when (exercise.type) { + ExerciseType.LISTEN_CHOOSE, ExerciseType.LISTEN_TYPE -> { + // Audio is the prompt — make a big, tappable speaker tile. + WordTile( + text = "Vypočuj si", + onSpeak = { onSpeak(exercise.answer) }, + modifier = Modifier.wrapContentSize(), + ) + } + else -> { + if (hero != null) { + Text( + hero, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = if (exercise.promptSk != null) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + // If the hero is Slovak, let the learner hear it. + if (exercise.promptSk != null) { + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + WordTile( + text = "Prehrať", + onSpeak = { onSpeak(exercise.promptSk!!) }, + modifier = Modifier.wrapContentSize(), + ) + } + } + } + } + + exercise.promptEn?.let { en -> + if (exercise.promptSk != null) { + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + en, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun ChoiceBody( + exercise: Exercise, + selected: String?, + onSelect: (String) -> Unit, + revealedCorrect: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { + exercise.choices.forEachIndexed { i, choice -> + val state = when { + revealedCorrect && choice == exercise.answer -> OptionState.CORRECT + revealedCorrect && choice == selected -> OptionState.WRONG + choice == selected -> OptionState.SELECTED + else -> OptionState.DEFAULT + } + OptionChip( + text = choice, + state = state, + number = i + 1, + onClick = { onSelect(choice) }, + ) + } + } +} + +@Composable +private fun SpeakBody( + exercise: Exercise, + selected: String?, + onSelect: (String) -> Unit, + onSpeak: (String) -> Unit, +) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md), + ) { + Text( + exercise.answer, + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + WordTile( + text = "Vypočuj si vzor", + onSpeak = { onSpeak(exercise.answer) }, + modifier = Modifier.wrapContentSize(), + ) + // No real recognizer in this build — confirm aloud to advance. + OptionChip( + text = "Povedal som to ✓", + state = if (selected == exercise.answer) OptionState.SELECTED else OptionState.DEFAULT, + onClick = { onSelect(exercise.answer) }, + ) + } +} + +@Composable +private fun WordBankBody( + exercise: Exercise, + typed: String, + onTyped: (String) -> Unit, + onSelect: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md)) { + // The sentence being assembled. + Box( + Modifier + .fillMaxWidth() + .height(64.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + typed.ifBlank { "…" }, + style = MaterialTheme.typography.headlineSmall, + color = if (typed.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + ) + } + // Word bank chips — tapping appends a word. + FlowChips( + words = exercise.choices, + onWord = { word -> + val next = if (typed.isBlank()) word else "$typed $word" + onTyped(next) + }, + ) + } +} + +@Composable +private fun FlowChips(words: List, onWord: (String) -> Unit) { + // Simple stacked rows of two to stay within the design-system primitives. + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { + words.chunked(2).forEach { row -> + androidx.compose.foundation.layout.Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + row.forEach { word -> + OptionChip( + text = word, + state = OptionState.DEFAULT, + onClick = { onWord(word) }, + modifier = Modifier.weight(1f), + ) + } + if (row.size == 1) Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun TypeBody( + exercise: Exercise, + typed: String, + onTyped: (String) -> Unit, +) { + val slovak = exercise.type != ExerciseType.TRANSLATE_SK_EN + OutlinedTextField( + value = typed, + onValueChange = onTyped, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.headlineSmall, + label = { Text(if (slovak) "Napíš po slovensky" else "Type in English") }, + placeholder = { Text(exercise.hint.orEmpty()) }, + singleLine = false, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + ) +} + +private fun instructionFor(type: ExerciseType): String = when (type) { + ExerciseType.MCQ -> "Vyber správnu odpoveď" + ExerciseType.TRANSLATE_SK_EN -> "Translate to English" + ExerciseType.TRANSLATE_EN_SK -> "Prelož do slovenčiny" + ExerciseType.LISTEN_CHOOSE -> "Počúvaj a vyber" + ExerciseType.LISTEN_TYPE -> "Počúvaj a napíš, čo počuješ" + ExerciseType.WORD_BANK -> "Zostav vetu zo slov" + ExerciseType.SPEAK -> "Povedz nahlas" + ExerciseType.MATCH_PAIRS -> "Vyber správnu dvojicu" + ExerciseType.FILL_CASE -> "Doplň správny tvar" + ExerciseType.ASPECT_CHOICE -> "Vyber správny vid" + ExerciseType.DIALOGUE_FILL -> "Doplň repliku" +} diff --git a/slovko/app/src/main/java/com/slovko/ui/profile/ProfileScreen.kt b/slovko/app/src/main/java/com/slovko/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..3d1ac4f --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/profile/ProfileScreen.kt @@ -0,0 +1,313 @@ +package com.slovko.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.component.CicmanyDivider +import com.slovko.core.designsystem.component.MajaMascot +import com.slovko.core.designsystem.component.StreakFlame +import com.slovko.core.designsystem.component.XpBar +import com.slovko.core.designsystem.slovkoColors +import com.slovko.core.designsystem.spacing +import com.slovko.domain.model.HeatDay +import com.slovko.domain.model.UserStats + +@Composable +fun ProfileScreen( + onOpenAchievements: () -> Unit, + onOpenSettings: () -> Unit, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val stats = state.stats + + if (state.loading || stats == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(MaterialTheme.spacing.screenEdge), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.lg), + ) { + ProfileHeader(stats = stats, onOpenSettings = onOpenSettings) + + StatGrid(stats = stats) + + HeatmapCard(heatmap = state.heatmap) + + AchievementsCard( + unlocked = state.achievementsUnlocked, + total = state.achievementsTotal, + onClick = onOpenAchievements, + ) + + Spacer(Modifier.height(MaterialTheme.spacing.md)) + } +} + +@Composable +private fun ProfileHeader(stats: UserStats, onOpenSettings: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Box(Modifier.fillMaxWidth()) { + IconButton( + onClick = onOpenSettings, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + Icons.Filled.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Column( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MajaMascot(pose = "proud", size = 104) + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + Text( + stats.displayName, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + "Level ${stats.level}", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + + Spacer(Modifier.height(MaterialTheme.spacing.md)) + val into = stats.xpIntoLevel + val needed = stats.xpForNextLevel + XpBar( + progress = if (needed <= 0) 1f else into.toFloat() / needed, + modifier = Modifier.fillMaxWidth(), + label = "$into / $needed XP to level ${stats.level + 1}", + ) + + Spacer(Modifier.height(MaterialTheme.spacing.md)) + StreakFlame(count = stats.currentStreak) + Text( + "day streak", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + } + } + } +} + +@Composable +private fun StatGrid(stats: UserStats) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { + Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { + StatTile("⭐", "${stats.totalXp}", "Total XP", Modifier.weight(1f)) + StatTile("📖", "${stats.wordsLearned}", "Words learned", Modifier.weight(1f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { + StatTile("🔥", "${stats.longestStreak}", "Longest streak", Modifier.weight(1f)) + StatTile("💎", "${stats.gems}", "Gems", Modifier.weight(1f)) + } + } +} + +@Composable +private fun StatTile(glyph: String, value: String, label: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier, + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(glyph, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + Text( + value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} + +@Composable +private fun HeatmapCard(heatmap: List) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(Modifier.padding(MaterialTheme.spacing.md)) { + Text( + "Your last weeks", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xs)) + CicmanyDivider() + Spacer(Modifier.height(MaterialTheme.spacing.md)) + + val gold = MaterialTheme.slovkoColors.gold + val empty = MaterialTheme.colorScheme.surfaceVariant + val maxXp = heatmap.maxOfOrNull { it.xp }?.coerceAtLeast(1) ?: 1 + val columns = 10 + + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs)) { + heatmap.chunked(columns).forEach { week -> + Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xxs)) { + for (col in 0 until columns) { + val day = week.getOrNull(col) + val color = when { + day == null -> empty + day.met -> gold + day.xp > 0 -> gold.copy( + alpha = (0.2f + 0.6f * (day.xp.toFloat() / maxXp)).coerceIn(0.2f, 0.85f), + ) + else -> empty + } + Box( + Modifier + .weight(1f) + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + .background(color), + ) + } + } + } + } + + Spacer(Modifier.height(MaterialTheme.spacing.sm)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Less", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(MaterialTheme.spacing.xs)) + listOf(0.2f, 0.45f, 0.7f, 1f).forEach { a -> + Box( + Modifier + .height(12.dp) + .width(12.dp) + .clip(RoundedCornerShape(3.dp)) + .background(gold.copy(alpha = a)), + ) + Spacer(Modifier.width(MaterialTheme.spacing.xxs)) + } + Spacer(Modifier.width(MaterialTheme.spacing.xxs)) + Text( + "More", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun AchievementsCard(unlocked: Int, total: Int, onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Row( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("🏆", style = MaterialTheme.typography.headlineMedium) + Spacer(Modifier.width(MaterialTheme.spacing.md)) + Column(Modifier.weight(1f)) { + Text( + "Achievements", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(MaterialTheme.spacing.xxs)) + Text( + "$unlocked/$total unlocked", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/slovko/app/src/main/java/com/slovko/ui/profile/ProfileViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..8be9132 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/profile/ProfileViewModel.kt @@ -0,0 +1,48 @@ +package com.slovko.ui.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.domain.model.Achievement +import com.slovko.domain.model.HeatDay +import com.slovko.domain.model.UserStats +import com.slovko.domain.repository.GamificationRepository +import com.slovko.domain.repository.ProgressRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +data class ProfileUiState( + val loading: Boolean = true, + val stats: UserStats? = null, + val heatmap: List = emptyList(), + val achievementsUnlocked: Int = 0, + val achievementsTotal: Int = 0, +) + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val progress: ProgressRepository, + private val gamification: GamificationRepository, +) : ViewModel() { + + val state: StateFlow = combine( + progress.observeUserStats(), + progress.observeHeatmap(70), + gamification.observeAchievements(), + ) { stats, heatmap, achievements -> + ProfileUiState( + loading = false, + stats = stats, + heatmap = heatmap, + achievementsUnlocked = achievements.count { it.unlocked }, + achievementsTotal = achievements.size, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = ProfileUiState(), + ) +} diff --git a/slovko/app/src/main/java/com/slovko/ui/settings/SettingsScreen.kt b/slovko/app/src/main/java/com/slovko/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..d9782ce --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/settings/SettingsScreen.kt @@ -0,0 +1,316 @@ +package com.slovko.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.slovko.core.designsystem.spacing +import com.slovko.domain.GamificationConfig +import com.slovko.domain.model.ThemeMode +import com.slovko.domain.model.UserSettings +import kotlin.math.roundToInt + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onExit: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + val settings by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding( + horizontal = MaterialTheme.spacing.screenEdge, + vertical = MaterialTheme.spacing.md, + ), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md), + ) { + LearningSection(settings, viewModel::update) + NotificationsSection(settings, viewModel::update) + AppearanceSection(settings, viewModel::update) + AiPartnerSection(settings, viewModel::update) + Spacer(Modifier.height(MaterialTheme.spacing.md)) + } + } +} + +@Composable +private fun SettingsCard( + title: String, + content: @Composable () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + Modifier + .fillMaxWidth() + .padding(MaterialTheme.spacing.lg), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md), + ) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + content() + } + } +} + +@Composable +private fun SettingLabel(text: String, hint: String? = null) { + Column { + Text(text, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + if (hint != null) { + Text( + hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun SwitchRow( + label: String, + hint: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + SettingLabel(label, hint) + Spacer(Modifier.width(MaterialTheme.spacing.md)) + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@Composable +private fun LearningSection( + s: UserSettings, + update: ((UserSettings) -> UserSettings) -> Unit, +) { + SettingsCard("Learning") { + SettingLabel("Daily goal", "How much Slovak each day") + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + GamificationConfig.DAILY_GOAL_TIERS.forEach { (label, goal) -> + FilterChip( + selected = s.dailyGoalXp == goal, + onClick = { update { it.copy(dailyGoalXp = goal) } }, + label = { Text("$label · $goal") }, + modifier = Modifier.weight(1f), + colors = FilterChipDefaults.filterChipColors(), + ) + } + } + + SettingLabel( + "Target retention", + "How well you want to remember: ${(s.targetRetention * 100).roundToInt()}%", + ) + Slider( + value = s.targetRetention, + onValueChange = { v -> update { it.copy(targetRetention = v) } }, + valueRange = 0.85f..0.95f, + steps = 9, + ) + + SwitchRow( + "Sound effects", + "Chimes and taps as you learn", + s.soundEffects, + ) { v -> update { it.copy(soundEffects = v) } } + SwitchRow( + "Autoplay audio", + "Hear new words automatically", + s.autoplayAudio, + ) { v -> update { it.copy(autoplayAudio = v) } } + SwitchRow( + "Speaking exercises", + "Practise saying words aloud", + s.speakingEnabled, + ) { v -> update { it.copy(speakingEnabled = v) } } + SwitchRow( + "Challenge mode", + "Hearts and a faster pace", + s.challengeMode, + ) { v -> update { it.copy(challengeMode = v) } } + } +} + +@Composable +private fun NotificationsSection( + s: UserSettings, + update: ((UserSettings) -> UserSettings) -> Unit, +) { + SettingsCard("Notifications") { + SwitchRow( + "Daily reminder", + "A gentle nudge to practise", + s.dailyReminderOn, + ) { v -> update { it.copy(dailyReminderOn = v) } } + SwitchRow( + "Streak reminder", + "Don't let the flame go out", + s.streakReminderOn, + ) { v -> update { it.copy(streakReminderOn = v) } } + SwitchRow( + "Review reminder", + "When cards are due", + s.reviewReminderOn, + ) { v -> update { it.copy(reviewReminderOn = v) } } + SwitchRow( + "Welcome back", + "A friendly hello after a break", + s.reengageOn, + ) { v -> update { it.copy(reengageOn = v) } } + + SettingLabel( + "Reminder time", + "Around ${formatHour(s.reminderHour)} each day", + ) + Slider( + value = s.reminderHour.toFloat(), + onValueChange = { v -> update { it.copy(reminderHour = v.roundToInt()) } }, + valueRange = 6f..22f, + steps = 15, + ) + } +} + +@Composable +private fun AppearanceSection( + s: UserSettings, + update: ((UserSettings) -> UserSettings) -> Unit, +) { + SettingsCard("Appearance") { + SettingLabel("Theme") + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.xs), + ) { + ThemeMode.entries.forEach { mode -> + FilterChip( + selected = s.themeMode == mode, + onClick = { update { it.copy(themeMode = mode) } }, + label = { Text(themeLabel(mode)) }, + modifier = Modifier.weight(1f), + ) + } + } + + SwitchRow( + "Reduced motion", + "Calmer, fewer animations", + s.reducedMotion, + ) { v -> update { it.copy(reducedMotion = v) } } + SwitchRow( + "Dynamic color", + "Match your device wallpaper", + s.dynamicColor, + ) { v -> update { it.copy(dynamicColor = v) } } + } +} + +@Composable +private fun AiPartnerSection( + s: UserSettings, + update: ((UserSettings) -> UserSettings) -> Unit, +) { + SettingsCard("AI partner") { + SwitchRow( + "Enable AI partner", + "Chat with Maja powered by your own model", + s.aiEnabled, + ) { v -> update { it.copy(aiEnabled = v) } } + + OutlinedTextField( + value = s.aiEndpoint, + onValueChange = { v -> update { it.copy(aiEndpoint = v) } }, + label = { Text("Endpoint URL") }, + singleLine = true, + enabled = s.aiEnabled, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = s.aiApiKey, + onValueChange = { v -> update { it.copy(aiApiKey = v) } }, + label = { Text("API key") }, + singleLine = true, + enabled = s.aiEnabled, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + } +} + +private fun themeLabel(mode: ThemeMode): String = when (mode) { + ThemeMode.SYSTEM -> "System" + ThemeMode.LIGHT -> "Light" + ThemeMode.DARK -> "Dark" +} + +private fun formatHour(hour: Int): String { + val h = hour.coerceIn(0, 23) + return "%02d:00".format(h) +} diff --git a/slovko/app/src/main/java/com/slovko/ui/settings/SettingsViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..7ac3269 --- /dev/null +++ b/slovko/app/src/main/java/com/slovko/ui/settings/SettingsViewModel.kt @@ -0,0 +1,33 @@ +package com.slovko.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.slovko.domain.model.UserSettings +import com.slovko.domain.repository.SettingsRepository +import com.slovko.work.NotificationScheduler +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val settings: SettingsRepository, + private val scheduler: NotificationScheduler, +) : ViewModel() { + + val state: StateFlow = settings.settings.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + UserSettings(), + ) + + fun update(transform: (UserSettings) -> UserSettings) { + viewModelScope.launch { + settings.update(transform) + scheduler.scheduleAll() + } + } +} From 97316a1f86624bff1356c8e9fe66d72ea43f38fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 19:11:29 +0000 Subject: [PATCH 4/6] Slovko: fix compile errors (getValue import, grade() Unit return, dp import); clean CI Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KcutcC6MjorhCn8EAfHDcP --- .github/workflows/android-ci.yml | 2 -- .../java/com/slovko/core/designsystem/component/Components.kt | 1 + .../main/java/com/slovko/data/repository/SrsRepositoryImpl.kt | 1 + .../src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt | 1 + 4 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index 02017f5..a467ae9 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -29,8 +29,6 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 - with: - build-root-directory: slovko - name: Make gradlew executable run: chmod +x ./gradlew diff --git a/slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt b/slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt index 50b391b..69f0072 100644 --- a/slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt +++ b/slovko/app/src/main/java/com/slovko/core/designsystem/component/Components.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip diff --git a/slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt b/slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt index c438cc0..10fdebd 100644 --- a/slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt +++ b/slovko/app/src/main/java/com/slovko/data/repository/SrsRepositoryImpl.kt @@ -79,6 +79,7 @@ class SrsRepositoryImpl @Inject constructor( lapses = next.lapses, ) if (existing == null) srsDao.insert(entity) else srsDao.update(entity) + Unit } override suspend fun bornCard(cardId: String, direction: CardDirection) = withContext(io) { diff --git a/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt b/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt index 8ce56d9..9afd135 100644 --- a/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt +++ b/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import com.slovko.core.designsystem.component.OptionChip import com.slovko.core.designsystem.component.OptionState import com.slovko.core.designsystem.component.WordTile From 23f0ac20c062c883960620bc82bce4837f4e178c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 09:08:27 +0000 Subject: [PATCH 5/6] Slovko: make lessons understandable for English speakers + fix match exercise - All exercise instructions and UI chrome now in English (was Slovak), so a non-Slovak speaker always knows what to do; only the taught Slovak content stays Slovak, with English glosses and meaning revealed in feedback. - Fix MATCH_PAIRS: render a real two-column matching UI from exercise.pairs (was wired to empty choices -> nothing to click) and grade it. - Render choice-based exercises (fill-case, dialogue, aspect) as tappable options instead of forcing Slovak typing when choices exist. - English-first feedback, completion, and error chrome across the lesson flow; cleaned remaining Slovak chrome in chat/phrasebook/chathub/onboarding. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KcutcC6MjorhCn8EAfHDcP --- .../java/com/slovko/ui/chat/ChatScreen.kt | 4 +- .../com/slovko/ui/chat/PhrasebookScreen.kt | 2 +- .../com/slovko/ui/chathub/ChatHubScreen.kt | 2 +- .../java/com/slovko/ui/lesson/LessonScreen.kt | 22 +- .../com/slovko/ui/lesson/LessonViewModel.kt | 23 +- .../slovko/ui/lesson/exercise/Exercises.kt | 224 +++++++++++++----- .../slovko/ui/onboarding/OnboardingScreen.kt | 2 +- 7 files changed, 195 insertions(+), 84 deletions(-) diff --git a/slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt b/slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt index a9cb2f1..2c99364 100644 --- a/slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt +++ b/slovko/app/src/main/java/com/slovko/ui/chat/ChatScreen.kt @@ -81,7 +81,7 @@ fun ChatScreen( TopAppBar( title = { Text( - state.title.ifBlank { "Konverzácia" }, + state.title.ifBlank { "Conversation" }, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, ) @@ -229,7 +229,7 @@ private fun Composer( value = draft, onValueChange = { draft = it }, modifier = Modifier.weight(1f), - placeholder = { Text("Napíš po slovensky…") }, + placeholder = { Text("Type in Slovak…") }, shape = RoundedCornerShape(24.dp), maxLines = 4, enabled = !sending, diff --git a/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt b/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt index 5e9447d..dc04ded 100644 --- a/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt +++ b/slovko/app/src/main/java/com/slovko/ui/chat/PhrasebookScreen.kt @@ -222,7 +222,7 @@ private fun EmptyPhrasebook(query: String) { MajaMascot(pose = "thinking") Spacer(Modifier.height(MaterialTheme.spacing.md)) Text( - if (query.isBlank()) "No phrases yet" else "Nič tu nie je", + if (query.isBlank()) "No phrases yet" else "Nothing here yet", style = MaterialTheme.typography.headlineSmall, ) Spacer(Modifier.height(MaterialTheme.spacing.xs)) diff --git a/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt b/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt index 359554d..894d50f 100644 --- a/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt +++ b/slovko/app/src/main/java/com/slovko/ui/chathub/ChatHubScreen.kt @@ -53,7 +53,7 @@ fun ChatHubScreen( ) { item { Text( - "Píšeme si", + "Chat practice", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, diff --git a/slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt index b6aa48a..974407d 100644 --- a/slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt +++ b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonScreen.kt @@ -84,16 +84,16 @@ private fun ErrorState(onExit: () -> Unit) { ) { MajaMascot(pose = "worried") Spacer(Modifier.height(MaterialTheme.spacing.md)) - Text("Túto lekciu sa nepodarilo načítať", style = MaterialTheme.typography.headlineSmall) + Text("Couldn't load this lesson", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(MaterialTheme.spacing.xs)) Text( - "Skús to prosím znova o chvíľu.", + "Please try again in a moment.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Spacer(Modifier.height(MaterialTheme.spacing.lg)) - PrimaryButton(text = "Späť", onClick = onExit) + PrimaryButton(text = "Back", onClick = onExit) } } @@ -116,7 +116,7 @@ private fun ActiveState( horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { IconButton(onClick = onExit) { - Icon(Icons.Filled.Close, contentDescription = "Zatvoriť") + Icon(Icons.Filled.Close, contentDescription = "Close") } LinearProgressIndicator( progress = { s.progress }, @@ -161,7 +161,7 @@ private fun ActiveState( val canCheck = s.selected != null || s.typed.isNotBlank() PrimaryButton( - text = if (s.feedback != null) "Pokračovať" else "Skontrolovať", + text = if (s.feedback != null) "Continue" else "Check", onClick = { if (s.feedback != null) viewModel.continueNext() else viewModel.check() }, @@ -207,7 +207,7 @@ private fun FeedbackBanner(feedback: Feedback, viewModel: LessonViewModel) { if (!feedback.correct) { Spacer(Modifier.height(MaterialTheme.spacing.xs)) Text( - "Správna odpoveď:", + "Correct answer:", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -266,14 +266,14 @@ private fun CompletedState(s: LessonUiState.Completed, onExit: () -> Unit) { MajaMascot(pose = "celebrating", size = 120) Spacer(Modifier.height(MaterialTheme.spacing.md)) Text( - "Lekcia hotová!", + "Lesson complete!", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) Spacer(Modifier.height(MaterialTheme.spacing.xs)) Text( - "Skvelá práca — len tak ďalej.", + "Great work — keep it up!", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -292,13 +292,13 @@ private fun CompletedState(s: LessonUiState.Completed, onExit: () -> Unit) { modifier = Modifier.weight(1f), ) StatCard( - label = "Presnosť", + label = "Accuracy", value = "$acc%", accent = MaterialTheme.slovkoColors.success, modifier = Modifier.weight(1f), ) StatCard( - label = "Čas", + label = "Time", value = formatDuration(secs), accent = MaterialTheme.colorScheme.tertiary, modifier = Modifier.weight(1f), @@ -308,7 +308,7 @@ private fun CompletedState(s: LessonUiState.Completed, onExit: () -> Unit) { Spacer(Modifier.height(MaterialTheme.spacing.xl)) PrimaryButton( - text = "Pokračovať", + text = "Continue", onClick = onExit, modifier = Modifier.fillMaxWidth(), ) diff --git a/slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt index de16377..11d441c 100644 --- a/slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt +++ b/slovko/app/src/main/java/com/slovko/ui/lesson/LessonViewModel.kt @@ -15,6 +15,9 @@ import com.slovko.domain.repository.SettingsRepository import com.slovko.domain.repository.SrsRepository import com.slovko.domain.usecase.GradeAnswerUseCase import com.slovko.domain.usecase.XpCalculator +import com.slovko.ui.lesson.exercise.ExerciseInput +import com.slovko.ui.lesson.exercise.MATCH_DONE +import com.slovko.ui.lesson.exercise.inputModeFor import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -147,18 +150,20 @@ class LessonViewModel @Inject constructor( val s = _state.value as? LessonUiState.Active ?: return if (s.feedback != null) return val ex = s.exercise - val answer = when (ex.type) { - ExerciseType.LISTEN_TYPE, ExerciseType.TRANSLATE_EN_SK, - ExerciseType.TRANSLATE_SK_EN, ExerciseType.WORD_BANK, - ExerciseType.DIALOGUE_FILL, ExerciseType.FILL_CASE -> s.typed + val answer = when (inputModeFor(ex)) { + ExerciseInput.TEXT, ExerciseInput.WORDBANK -> s.typed else -> s.selected.orEmpty() } if (answer.isBlank()) return - val result = gradeAnswer(ex, answer) - results.add(ex.type to result.correct) + val correct = if (ex.type == ExerciseType.MATCH_PAIRS) { + answer == MATCH_DONE + } else { + gradeAnswer(ex, answer).correct + } + results.add(ex.type to correct) - if (result.correct) { + if (correct) { if (clearedIds.add(ex.id)) cleared++ if (correctlyAnswered.none { it.id == ex.id }) { correctlyAnswered.add(ex) @@ -167,7 +172,7 @@ class LessonViewModel @Inject constructor( _state.value = s.copy( feedback = Feedback( correct = true, - title = "Správne!", + title = "Správne! — Correct 🎉", correctAnswer = ex.answer, ), ) @@ -175,7 +180,7 @@ class LessonViewModel @Inject constructor( _state.value = s.copy( feedback = Feedback( correct = false, - title = "Skoro", + title = "Not quite", correctAnswer = ex.answer, explanation = ex.hint, ), diff --git a/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt b/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt index 9afd135..fe3b403 100644 --- a/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt +++ b/slovko/app/src/main/java/com/slovko/ui/lesson/exercise/Exercises.kt @@ -1,8 +1,10 @@ package com.slovko.ui.lesson.exercise +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,6 +15,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -26,9 +34,24 @@ import com.slovko.core.designsystem.spacing import com.slovko.domain.model.Exercise import com.slovko.domain.model.ExerciseType +/** Sentinel the match exercise emits via onSelect once every pair is connected. */ +const val MATCH_DONE = "DONE" + +/** What kind of input an exercise needs — shared by the renderer and the ViewModel. */ +enum class ExerciseInput { CHOICE, TEXT, WORDBANK, MATCH, SPEAK } + +fun inputModeFor(exercise: Exercise): ExerciseInput = when (exercise.type) { + ExerciseType.MATCH_PAIRS -> ExerciseInput.MATCH + ExerciseType.SPEAK -> ExerciseInput.SPEAK + ExerciseType.WORD_BANK -> ExerciseInput.WORDBANK + ExerciseType.LISTEN_TYPE -> ExerciseInput.TEXT + else -> if (exercise.choices.isNotEmpty()) ExerciseInput.CHOICE else ExerciseInput.TEXT +} + /** - * Renders the body of a single exercise according to its [Exercise.type]. - * Pure presentation — all state lives in the caller (LessonViewModel). + * Renders the body of a single exercise. All instructions and chrome are in + * ENGLISH so a non-Slovak speaker always knows what to do; only the Slovak + * content being taught is shown in Slovak. */ @Composable fun ExerciseBody( @@ -44,32 +67,30 @@ fun ExerciseBody( PromptHeader(exercise, onSpeak) Spacer(Modifier.height(MaterialTheme.spacing.lg)) - when (exercise.type) { - ExerciseType.MCQ, - ExerciseType.TRANSLATE_SK_EN, - ExerciseType.LISTEN_CHOOSE, - ExerciseType.ASPECT_CHOICE, - -> ChoiceBody(exercise, selected, onSelect, revealedCorrect) - - ExerciseType.WORD_BANK -> WordBankBody(exercise, typed, onTyped, onSelect) - - ExerciseType.TRANSLATE_EN_SK, - ExerciseType.LISTEN_TYPE, - ExerciseType.FILL_CASE, - ExerciseType.DIALOGUE_FILL, - -> TypeBody(exercise, typed, onTyped) - - ExerciseType.SPEAK -> SpeakBody(exercise, selected, onSelect, onSpeak) - - ExerciseType.MATCH_PAIRS -> ChoiceBody(exercise, selected, onSelect, revealedCorrect) + when (inputModeFor(exercise)) { + ExerciseInput.CHOICE -> ChoiceBody(exercise, selected, onSelect, revealedCorrect) + ExerciseInput.WORDBANK -> WordBankBody(exercise, typed, onTyped) + ExerciseInput.TEXT -> TypeBody(exercise, typed, onTyped) + ExerciseInput.SPEAK -> SpeakBody(exercise, selected, onSelect, onSpeak) + ExerciseInput.MATCH -> MatchBody(exercise, onSelect, onSpeak) } } } @Composable private fun PromptHeader(exercise: Exercise, onSpeak: (String) -> Unit) { - val instruction = instructionFor(exercise.type) - val hero = exercise.promptSk ?: exercise.promptEn + val instruction = instructionFor(exercise) + + // Which language is the question in? For "produce Slovak" tasks the English + // sentence is the prompt; otherwise prefer the Slovak hero word. + val heroIsEnglishTask = exercise.type == ExerciseType.TRANSLATE_EN_SK || + exercise.type == ExerciseType.WORD_BANK + val hero = if (heroIsEnglishTask) { + exercise.promptEn ?: exercise.promptSk + } else { + exercise.promptSk ?: exercise.promptEn + } + val heroIsSlovak = hero != null && hero == exercise.promptSk && !heroIsEnglishTask Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Text( @@ -82,9 +103,8 @@ private fun PromptHeader(exercise: Exercise, onSpeak: (String) -> Unit) { when (exercise.type) { ExerciseType.LISTEN_CHOOSE, ExerciseType.LISTEN_TYPE -> { - // Audio is the prompt — make a big, tappable speaker tile. WordTile( - text = "Vypočuj si", + text = "🔊 Tap to listen", onSpeak = { onSpeak(exercise.answer) }, modifier = Modifier.wrapContentSize(), ) @@ -95,16 +115,15 @@ private fun PromptHeader(exercise: Exercise, onSpeak: (String) -> Unit) { hero, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, - color = if (exercise.promptSk != null) MaterialTheme.colorScheme.primary + color = if (heroIsSlovak) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, ) - // If the hero is Slovak, let the learner hear it. - if (exercise.promptSk != null) { + if (heroIsSlovak) { Spacer(Modifier.height(MaterialTheme.spacing.sm)) WordTile( - text = "Prehrať", - onSpeak = { onSpeak(exercise.promptSk!!) }, + text = "🔊 Tap to hear", + onSpeak = { onSpeak(hero) }, modifier = Modifier.wrapContentSize(), ) } @@ -112,8 +131,9 @@ private fun PromptHeader(exercise: Exercise, onSpeak: (String) -> Unit) { } } - exercise.promptEn?.let { en -> - if (exercise.promptSk != null) { + // Show the English gloss whenever the hero is Slovak and a gloss exists. + if (heroIsSlovak) { + exercise.promptEn?.let { en -> Spacer(Modifier.height(MaterialTheme.spacing.xs)) Text( en, @@ -151,6 +171,82 @@ private fun ChoiceBody( } } +@Composable +private fun MatchBody( + exercise: Exercise, + onSelect: (String) -> Unit, + onSpeak: (String) -> Unit, +) { + // Left = Slovak (in order), Right = English (shuffled), matched by index. + val pairs = exercise.pairs + val rightOrder = remember(exercise.id) { pairs.indices.shuffled() } + val matched = remember(exercise.id) { mutableStateListOf() } + var selectedLeft by remember(exercise.id) { mutableIntStateOf(-1) } + var wrongFlash by remember(exercise.id) { mutableIntStateOf(-1) } + + LaunchedEffect(matched.size) { + if (pairs.isNotEmpty() && matched.size == pairs.size) onSelect(MATCH_DONE) + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + // Slovak column + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + pairs.forEachIndexed { i, pair -> + val done = matched.contains(i) + OptionChip( + text = pair.first, + state = when { + done -> OptionState.CORRECT + selectedLeft == i -> OptionState.SELECTED + else -> OptionState.DEFAULT + }, + onClick = { + if (!done) { + selectedLeft = i + onSpeak(pair.first) + } + }, + ) + } + } + // English column (shuffled) + Column( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), + ) { + rightOrder.forEach { idx -> + val done = matched.contains(idx) + OptionChip( + text = pairs[idx].second, + state = when { + done -> OptionState.CORRECT + wrongFlash == idx -> OptionState.WRONG + else -> OptionState.DEFAULT + }, + onClick = { + if (!done) { + if (selectedLeft == idx) { + matched.add(idx) + selectedLeft = -1 + wrongFlash = -1 + } else if (selectedLeft >= 0) { + wrongFlash = idx + selectedLeft = -1 + } + } + }, + ) + } + } + } +} + @Composable private fun SpeakBody( exercise: Exercise, @@ -170,14 +266,21 @@ private fun SpeakBody( color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center, ) + exercise.promptEn?.let { + Text( + it, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } WordTile( - text = "Vypočuj si vzor", + text = "🔊 Tap to hear it", onSpeak = { onSpeak(exercise.answer) }, modifier = Modifier.wrapContentSize(), ) - // No real recognizer in this build — confirm aloud to advance. OptionChip( - text = "Povedal som to ✓", + text = "I said it ✓", state = if (selected == exercise.answer) OptionState.SELECTED else OptionState.DEFAULT, onClick = { onSelect(exercise.answer) }, ) @@ -189,24 +292,23 @@ private fun WordBankBody( exercise: Exercise, typed: String, onTyped: (String) -> Unit, - onSelect: (String) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.md)) { - // The sentence being assembled. + // The sentence being assembled — tap to clear and start over. Box( Modifier .fillMaxWidth() - .height(64.dp), + .height(64.dp) + .clickable { onTyped("") }, contentAlignment = Alignment.CenterStart, ) { Text( - typed.ifBlank { "…" }, + typed.ifBlank { "Tap the words below…" }, style = MaterialTheme.typography.headlineSmall, color = if (typed.isBlank()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, ) } - // Word bank chips — tapping appends a word. FlowChips( words = exercise.choices, onWord = { word -> @@ -219,10 +321,9 @@ private fun WordBankBody( @Composable private fun FlowChips(words: List, onWord: (String) -> Unit) { - // Simple stacked rows of two to stay within the design-system primitives. Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm)) { words.chunked(2).forEach { row -> - androidx.compose.foundation.layout.Row( + Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.sm), ) { @@ -246,31 +347,36 @@ private fun TypeBody( typed: String, onTyped: (String) -> Unit, ) { - val slovak = exercise.type != ExerciseType.TRANSLATE_SK_EN + val typeEnglish = exercise.type == ExerciseType.TRANSLATE_SK_EN OutlinedTextField( value = typed, onValueChange = onTyped, modifier = Modifier.fillMaxWidth(), textStyle = MaterialTheme.typography.headlineSmall, - label = { Text(if (slovak) "Napíš po slovensky" else "Type in English") }, - placeholder = { Text(exercise.hint.orEmpty()) }, + label = { Text(if (typeEnglish) "Type in English" else "Type in Slovak") }, + placeholder = { exercise.hint?.let { Text(it) } }, singleLine = false, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - ), + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), ) } -private fun instructionFor(type: ExerciseType): String = when (type) { - ExerciseType.MCQ -> "Vyber správnu odpoveď" - ExerciseType.TRANSLATE_SK_EN -> "Translate to English" - ExerciseType.TRANSLATE_EN_SK -> "Prelož do slovenčiny" - ExerciseType.LISTEN_CHOOSE -> "Počúvaj a vyber" - ExerciseType.LISTEN_TYPE -> "Počúvaj a napíš, čo počuješ" - ExerciseType.WORD_BANK -> "Zostav vetu zo slov" - ExerciseType.SPEAK -> "Povedz nahlas" - ExerciseType.MATCH_PAIRS -> "Vyber správnu dvojicu" - ExerciseType.FILL_CASE -> "Doplň správny tvar" - ExerciseType.ASPECT_CHOICE -> "Vyber správny vid" - ExerciseType.DIALOGUE_FILL -> "Doplň repliku" +private fun instructionFor(exercise: Exercise): String = when (inputModeFor(exercise)) { + ExerciseInput.MATCH -> "Tap a Slovak word, then its English match" + ExerciseInput.SPEAK -> "Say it out loud" + ExerciseInput.WORDBANK -> "Tap the words to build the Slovak sentence" + ExerciseInput.TEXT -> when (exercise.type) { + ExerciseType.LISTEN_TYPE -> "Listen and type what you hear" + ExerciseType.TRANSLATE_SK_EN -> "Type the English translation" + ExerciseType.TRANSLATE_EN_SK -> "Type it in Slovak" + else -> "Type the missing word" + } + ExerciseInput.CHOICE -> when (exercise.type) { + ExerciseType.LISTEN_CHOOSE -> "Listen, then tap what you heard" + ExerciseType.TRANSLATE_SK_EN -> "Tap the English meaning" + ExerciseType.TRANSLATE_EN_SK -> "Tap the Slovak translation" + ExerciseType.ASPECT_CHOICE -> "Tap the correct verb" + ExerciseType.FILL_CASE -> "Tap the correct form" + ExerciseType.DIALOGUE_FILL -> "Tap the best reply" + else -> "Tap the correct answer" + } } diff --git a/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt b/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt index 979bc99..1dc5b5c 100644 --- a/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt +++ b/slovko/app/src/main/java/com/slovko/ui/onboarding/OnboardingScreen.kt @@ -62,7 +62,7 @@ private val GOALS = listOf( private data class LevelOption(val title: String, val blurb: String, val emoji: String) private val LEVELS = listOf( - LevelOption("Brand new", "Začíname od nuly — and that's perfect.", "🌱"), + LevelOption("Brand new", "Starting from zero — and that's perfect.", "🌱"), LevelOption("I know a little", "A few words and phrases already.", "🌤️"), LevelOption("I can get by", "I can hold a simple conversation.", "🏔️"), ) From a588cb2a6842e2021b948e525c48cbcdcbd18ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 11:11:10 +0000 Subject: [PATCH 6/6] Slovko: remove audio-indistinguishable listen exercises + add CI guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Unit 0 phonics unit tried to teach spelling/stress rules as listening tasks that TTS cannot support: - 'Listen and choose' between í/i/y/ý — but y=i and ý=í are pronounced IDENTICALLY in Slovak (spelling rule, not a sound). Replaced with an MCQ that teaches that fact. - 'Where is the stress' with options A-hoj vs a-HOJ — TTS can't render contrastive stress. Replaced with a real listen-and-pick between two distinct words (mäso vs stôl). - Bumped curriculum version 1 -> 2 so existing installs re-seed the fix. - Added ListenChoiceDistinctTest: fails the build if any LISTEN_CHOOSE has homophone options (i/y, í/ý), options differing only by case/spacing, or an answer missing from its choices. Legit audible contrasts (sud/súd, ď/ť) pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KcutcC6MjorhCn8EAfHDcP --- .../src/main/assets/curriculum/manifest.json | 2 +- .../main/assets/curriculum/unit0-zvuky.json | 23 +++--- .../content/ListenChoiceDistinctTest.kt | 73 +++++++++++++++++++ 3 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 slovko/app/src/test/java/com/slovko/content/ListenChoiceDistinctTest.kt diff --git a/slovko/app/src/main/assets/curriculum/manifest.json b/slovko/app/src/main/assets/curriculum/manifest.json index cb5d0b7..bf93c07 100644 --- a/slovko/app/src/main/assets/curriculum/manifest.json +++ b/slovko/app/src/main/assets/curriculum/manifest.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "skillFiles": [ "unit0-zvuky.json", "unit1-ahoj.json", diff --git a/slovko/app/src/main/assets/curriculum/unit0-zvuky.json b/slovko/app/src/main/assets/curriculum/unit0-zvuky.json index bb2a58d..454979d 100644 --- a/slovko/app/src/main/assets/curriculum/unit0-zvuky.json +++ b/slovko/app/src/main/assets/curriculum/unit0-zvuky.json @@ -44,12 +44,12 @@ { "id": "e0-abc-3", "orderIndex": 2, - "type": "LISTEN_CHOOSE", - "promptEn": "Listen and choose the sound: is it short or long?", - "answer": "í", - "acceptable": ["í"], - "choices": ["í", "i", "y", "ý"], - "audioKey": "i-dlhe", + "type": "MCQ", + "promptEn": "Good to know: Slovak 'i' and 'y' are pronounced exactly the same (and so are 'í' and 'ý'). Which one is just spelled with extra length?", + "answer": "í and ý (long ee)", + "acceptable": ["í and ý (long ee)"], + "choices": ["í and ý (long ee)", "i and y (long ee)", "they are all long", "none are long"], + "hint": "The dĺžeň (´) marks length; y vs i is only a spelling rule, never a sound difference.", "register": "neutral" }, { @@ -302,11 +302,12 @@ "id": "e0-tp-5", "orderIndex": 4, "type": "LISTEN_CHOOSE", - "promptEn": "Where is the stress in 'ahoj'?", - "answer": "A-hoj", - "acceptable": ["a-hoj", "first"], - "choices": ["A-hoj", "a-HOJ"], - "audioKey": "ahoj", + "promptEn": "Which word do you hear?", + "answer": "mäso", + "acceptable": ["mäso"], + "choices": ["mäso", "stôl"], + "audioKey": "maso", + "hint": "mäso = meat, stôl = table.", "register": "neutral" }, { diff --git a/slovko/app/src/test/java/com/slovko/content/ListenChoiceDistinctTest.kt b/slovko/app/src/test/java/com/slovko/content/ListenChoiceDistinctTest.kt new file mode 100644 index 0000000..208cf9e --- /dev/null +++ b/slovko/app/src/test/java/com/slovko/content/ListenChoiceDistinctTest.kt @@ -0,0 +1,73 @@ +package com.slovko.content + +import com.slovko.data.AppJson +import com.slovko.data.seed.UnitFileDto +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.util.Locale + +/** + * Guards against LISTEN_CHOOSE exercises whose options cannot be told apart by + * ear via TTS. The classic offenders in Slovak: + * - i/y and í/ý are pronounced IDENTICALLY (a spelling rule, not a sound), + * - options that differ only by capitalisation / spacing (e.g. fake stress + * markers "A-hoj" vs "a-HOJ") cannot be produced by TTS either. + * + * Legitimate audible contrasts (word-level vowel length like sud/súd, or the + * soft consonants ď/ť/ň/ľ) are intentionally NOT flagged. + */ +class ListenChoiceDistinctTest { + + private val curriculumDir = File("src/main/assets/curriculum") + + /** Collapse the orthographic homophones y↔i and ý↔í. */ + private fun soundKey(s: String): String = + s.lowercase(Locale.ROOT).replace("ý", "í").replace("y", "i").trim() + + /** Strip case, spacing and punctuation, keeping letters (incl. diacritics). */ + private fun shapeKey(s: String): String = + s.lowercase(Locale.ROOT).filter { it.isLetter() } + + @Test + fun listenChooseOptionsAreAudiblyDistinct() { + val files = curriculumDir.listFiles { f -> f.name.matches(Regex("unit.*\\.json")) } + ?.sortedBy { it.name } + ?: emptyList() + assertTrue("No curriculum unit files found in $curriculumDir", files.isNotEmpty()) + + val problems = mutableListOf() + for (file in files) { + val unit = AppJson.decodeFromString(UnitFileDto.serializer(), file.readText()) + for (skill in unit.skills) { + for (lesson in skill.lessons) { + for (ex in lesson.exercises) { + if (ex.type.uppercase(Locale.ROOT) != "LISTEN_CHOOSE") continue + val choices = ex.choices + val where = "${file.name} / ${ex.id}" + + if (choices.size < 2) { + problems += "$where: LISTEN_CHOOSE needs >= 2 choices" + continue + } + val sounds = choices.map { soundKey(it) } + if (sounds.toSet().size != sounds.size) { + problems += "$where: options are homophones (i/y or í/ý sound the same): $choices" + } + val shapes = choices.map { shapeKey(it) } + if (shapes.toSet().size != shapes.size) { + problems += "$where: options differ only by case/spacing — TTS can't render them: $choices" + } + if (ex.answer.isNotBlank() && choices.none { it == ex.answer }) { + problems += "$where: answer '${ex.answer}' is not among the choices" + } + } + } + } + } + assertTrue( + "Found audio-indistinguishable LISTEN_CHOOSE exercises:\n${problems.joinToString("\n")}", + problems.isEmpty(), + ) + } +}