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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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

- 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
Binary file added slovko/.gradle/8.14.3/checksums/checksums.lock
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added slovko/.gradle/8.14.3/fileChanges/last-build.bin
Binary file not shown.
Binary file added slovko/.gradle/8.14.3/fileHashes/fileHashes.bin
Binary file not shown.
Binary file added slovko/.gradle/8.14.3/fileHashes/fileHashes.lock
Binary file not shown.
Empty file.
Binary file not shown.
2 changes: 2 additions & 0 deletions slovko/.gradle/buildOutputCleanup/cache.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#Sat Jun 27 18:17:31 UTC 2026
gradle.version=8.14.3
Binary file added slovko/.gradle/buildOutputCleanup/outputFiles.bin
Binary file not shown.
Binary file added slovko/.gradle/file-system.probe
Binary file not shown.
Empty file.
569 changes: 569 additions & 0 deletions slovko/DESIGN.md

Large diffs are not rendered by default.

42 changes: 42 additions & 0 deletions slovko/README.md
Original file line number Diff line number Diff line change
@@ -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
```
91 changes: 91 additions & 0 deletions slovko/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions slovko/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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 { <init>(); }
-dontwarn androidx.room.paging.**

# Hilt / Dagger generated
-dontwarn com.google.errorprone.annotations.**
71 changes: 71 additions & 0 deletions slovko/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.VIBRATE" />

<application
android:name=".SlovkoApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Slovko.Starting">

<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Slovko.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="slovko" />
</intent-filter>
</activity>

<!-- WorkManager: use on-demand initialization via Configuration.Provider -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge"
xmlns:tools="http://schemas.android.com/tools">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove"
xmlns:tools="http://schemas.android.com/tools" />
</provider>

<receiver
android:name=".work.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>

<receiver
android:name=".widget.SlovkoWidgetReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/slovko_widget_info" />
</receiver>

</application>
</manifest>
20 changes: 20 additions & 0 deletions slovko/app/src/main/assets/curriculum/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"version": 2,
"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"
}
50 changes: 50 additions & 0 deletions slovko/app/src/main/assets/curriculum/phrasebook.json
Original file line number Diff line number Diff line change
@@ -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 }
]
}
Loading
Loading