Те, кто учил французский, знают, как сложно освоиться в кругу их числительных. Во французском языке уживаются сразу две системы счисления - привычная нам десятеричная и кельтско-норманнская двадцатеричная, она же вигезимальная.
Mille quatre cent quatre-vingt-deux
-- поет Грингуар про 1482 год. Здесь 400 - в десятеричной системе (quatre cent), а 80 - уже в 20-ричной (quatre-vingt).
И хотя программиста не запугаешь даже 16-ричной, все-таки метаться между системами счисления в уме, когда ты уже стоишь у кассы и должен расплатиться, причем, по нынешним временам, наличными, - тот еще квест. Поэтому давайте доведем знание числительных до автоматизма.
Грамматика
Для начала давайте напишем миниатюрный переводчик с числового на французский. Точнее, конвертер из целого числа от 0 до 100 в строку.
Вот наши кирпичики, из которых можно составить любое число этого диапазона:
private val units = arrayOf(
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf"
)
private val tenToSixteen = arrayOf(
"dix", "onze", "douze", "treize", "quatorze", "quinze", "seize"
)
private val tens = arrayOf(
"", "dix", "vingt", "trente", "quarante", "cinquante", "soixante"
)
Их всего три вида - от 0 до 9, от 10 до 16, а также десятки от 10 до 60.
Названия чисел от 0 до 16 мы получим из этих массивов, а от 17 до 19 - прибавкой "dix-" к числу единиц: dix-sept, dix-huit, dix-neuf:
private fun from0to19(n: Int): String = when (n) {
in 0..9 -> units[n]
in 10..16 -> tenToSixteen[n - 10]
in 17..19 -> "dix-" + units[n - 10]
else -> error("Bad 1..19 input: $n")
}
От 20 до 69 французы используют десятеричную систему счисления. Алгоритм перевода числа в нее все мы, наверное, писали, когда учились программировать: получаем частное и остаток от деления на 10, то есть, соответственно, десятки и единицы. Берем их названия из соответствующих массивов. Учитываем, что для чисел, оканчивающихся единицей (21/31/41/51/61), надо прибавлять слово "et":
private fun from20to69(n: Int): String {
val d = n / 10 // 2..6
val r = n % 10 // 0..9
return when {
r == 0 -> tens[d]
r == 1 -> "${tens[d]} et un"
else -> "${tens[d]}-${units[r]}"
}
}
Числа от 70 до 79 называются "60 + x". Поэтому вычтем из такого числа 60, а для остатка вызовем уже известную нам функцию from0to19()
:
private fun from70to79(n: Int): String {
val r = n - 60
return when (r) {
11 -> "soixante et onze"
in 10..19 -> "soixante-" + from0to19(r)
else -> error("Unexpected 70s remainder $r")
}
}
От 81 до 99 числа именуются по схеме "4-20-x", поэтому мы вычитаем из числа 80 и снова вызываем функцию from0to19()
:
private fun from80to99(n: Int): String {
return if (n == 80) {
"quatre-vingts"
} else {
"quatre-vingt-" + from0to19(n - 80)
}
}
Добавив особый случай для числа 100 ("cent"), сведем все варианты в функцию:
fun toFrench(n: Int): String {
require(n in 0..100) { "Only 0..100 supported in this demo" }
return when {
n <= 19 -> from0to19(n)
n in 20..69 -> from20to69(n)
n in 70..79 -> from70to79(n)
n in 80..99 -> from80to99(n)
n == 100 -> "cent"
else -> error("Unhandled number $n")
}
}
В Java мы бы свели эту группу вспомогательных функций в один класс с модификатором public static
, но в Kotlin в таких случаях принято использовать синглтон:
object FrenchNumbers {
private val units = arrayOf(
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf"
)
private val tenToSixteen = arrayOf(
"dix", "onze", "douze", "treize", "quatorze", "quinze", "seize"
)
private val tens = arrayOf(
"", "dix", "vingt", "trente", "quarante", "cinquante", "soixante"
)
fun toFrench(n: Int): String { ... }
private fun from80to99(n: Int): String { ... }
private fun from20to69(n: Int): String { ... }
private fun from70to79(n: Int): String { ... }
private fun from0to19(n: Int): String = ...
}
SRS-алгоритм
Имхо, смартфон и SRS-системы - идеальная пара. Чему, как не смартфону, удобно следить за прогрессом своего владельца и присылать напоминания, когда пора повторить очередное слово?
Возьмем общеизвестный алгоритм SuperMemo и построим на его основе систему интервальных повторений. Начнем с карточки одного числа, где будут храниться параметры для вычислений:
data class CardSrs(
var ef: Double = 2.5,
var interval: Int = 0,
var reps: Int = 0,
var lapses: Int = 0,
var due: Long = todayEpoch()
)
где
ef
- коэффициент легкости (easy factor), по умолчанию обычно берут 2.5;interval
- число дней до следующего показа;reps
- число успешных повторов подряд;lapses
- число провалов;due
- дата следующего показа (Epoch Day - число суток с 1.01.1970), которая вычисляется так:
fun todayEpoch(): Long = LocalDate.now().toEpochDay()
Так как у нас всего по 5 параметров для 100 чисел, прогресс пользователя можно хранить прямо в Preferences DataStore, не используя базу данных:
private val Context.srsDataStore by preferencesDataStore("french_srs_numbers")
Здесь preferencesDataStore()
- делегат из библиотеки Jetpack DataStore, который при первом доступе лениво и потокобезопасно создает и кэширует один экземпляр DataStore<Preferences>
для заданного имени файла ("french_srs_numbers"
). При последующих доступах этот делегат вернет тот же экземпляр.
srsDataStore
мы объявили как свойство-расширение типа Context
, чтобы в дальнейшем писать appContext.srsDataStore
.
Сам контекст вместе с будущими вспомогательными методами сложим в еще один синглтон:
object Srs {
private lateinit var appCtx: Context
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val numMap = mutableMapOf<Int, CardSrs>().apply {
for (n in 0..100) put(n, CardSrs())
}
Контекст и скоуп лучше вынести в свойства, чтобы повторно их использовать во всех функциях.
При этом контекст пришлось определить с модификатором lateinit
: это идиоматичный способ объявить non-null свойство и при этом легально отложить его инициализацию до тех пор, пока контекст реально появится.
Удобно держать единый скоуп scope
для всех фоновых операций. Здесь мы его строим на базе контекста, состоящего из SupervisorJob()
и диспетчера IO. SupervisorJob (Job
-надзиратель) позволяет выключить механизм распространения исключений. Каждая корутина, запущенная под надзором SupervisorJob
, работает независимо с точки зрения обработки ошибок. Что касается диспетчера IO, то он удобен для работы с файловой системой. Подробности в моем большом курсе по корутинам.
Свойство numMap
хранит числа и их карточки в виде MutableMap.
Чтобы загрузить сохраненные карточки из DataStore, достаточно обратиться к нему через наше расширение srsDataStore
:
suspend fun init(context: Context) {
appCtx = context.applicationContext
val prefs = appCtx.srsDataStore.data.first()
for (n in 0..100) {
val p = "n$n"
val ef = prefs[doublePreferencesKey("$p.ef")] ?: 2.5
val interval = prefs[intPreferencesKey("$p.interval")] ?: 0
val reps = prefs[intPreferencesKey("$p.reps")] ?: 0
val lapses = prefs[intPreferencesKey("$p.lapses")] ?: 0
val due = prefs[longPreferencesKey("$p.due")] ?: todayEpoch()
numMap[n] = CardSrs(ef, interval, reps, lapses, due)
}
}
При сохранении карточки мы учитываем результат ее показа. Если пользователь ее вспомнил, то обновляем параметры, в противном случае - сбрасываем до дефолтных:
fun updateNumber(n: Int, ok: Boolean) {
val st = numMap.getValue(n)
if (!ok) {
st.reps = 0
st.interval = 1
st.due = todayEpoch() + 1
st.lapses += 1
} else {
if (st.reps == 0) st.interval = 1
else if (st.reps == 1) st.interval = 6
else st.interval = round(st.interval * st.ef).toInt()
st.ef = maxOf(1.3, st.ef + 0.1)
st.reps += 1
st.due = todayEpoch() + st.interval
}
saveNumberAsync(n, st)
}
Назначенный интервал It+1 зависит от числа идущих подряд успешных повторов rt:
EF (ease factor) в случае успеха увеличивается на 0.1. Обратите внимание, что для значения EF установлен минимальный порог, равный 1.3.
Собственно процесс сохранения сделаем асинхронным, как предполагает suspend-метод DataStore.edit()
. Запустим корутину из ранее созданного скоупа и в ней вызовем edit()
:
private fun saveNumberAsync(n: Int, st: CardSrs) {
if (!this::appCtx.isInitialized) return
val p = "n$n"
scope.launch {
appCtx.srsDataStore.edit { e ->
e[doublePreferencesKey("$p.ef")] = st.ef
e[intPreferencesKey("$p.interval")] = st.interval
e[intPreferencesKey("$p.reps")] = st.reps
e[intPreferencesKey("$p.lapses")] = st.lapses
e[longPreferencesKey("$p.due")] = st.due
}
}
}
Впрочем, удобнее будет вынести проверку, инициализован ли контекст, в отдельный метод:
fun isReady(): Boolean = this::appCtx.isInitialized
Чтобы получить список чисел, созревших для интервальных повторений, мы просто сортируем все карточки по дате due
и берем первые limit
элементов, начиная с текущей даты:
@RequiresApi(Build.VERSION_CODES.O)
fun topDueNumbers(limit: Int = 5): List<Int> =
numMap.entries
.sortedBy { it.value.due }
.filter { it.value.due <= todayEpoch() }
.map { it.key }
.take(limit)
Вот мы и написали логику нашей SRS-системы.
Ударный режим
Что так привлекает нас в Дуолинго? Конечно же, функция ударного режима aka стрик!
Давайте позаимствуем эту идею.
data class StreakSnapshot(
val current: Int,
val best: Int,
val week: List<Boolean>,
val todayActive: Boolean
)
Это компактный снимок (snapshot) состояния серии занятий пользователя, который удобно передать в UI одним объектом.
Снова создадим синглтон:
object Streak {
private lateinit var appCtx: Context
private val scope = CoroutineScope(Dispatchers.IO)
fun init(context: Context) { appCtx = context.applicationContext }
private fun todayEpoch(): Long = LocalDate.now().toEpochDay()
// ...
}
Пока все аналогично предыдущему случаю.
Обновим статистику активности, запустив в заданном выше скоупе (чтобы не блокировать UI) корутину для работы с DataStore:
fun markTodayActive() {
if (!::appCtx.isInitialized) return
scope.launch {
val ds = appCtx.streakDataStore
val prefs = ds.data.first()
val cur = prefs[KEY_CUR] ?: 0
val best = prefs[KEY_BEST] ?: 0
val last = prefs[KEY_LAST] ?: Long.MIN_VALUE
val set = (prefs[KEY_DAYS] ?: emptySet()).toMutableSet()
val today = todayEpoch()
if (!set.contains(today.toString())) set += today.toString()
val newCur = when {
last == today -> cur
last == today - 1 -> (cur + 1)
else -> 1
}
val newBest = maxOf(best, newCur)
val keepAfter = today - 35
set.removeAll { it.toLongOrNull()?.let { d -> d < keepAfter } == true }
ds.edit {
it[KEY_CUR] = newCur
it[KEY_BEST] = newBest
it[KEY_LAST] = today
it[KEY_DAYS] = set
}
}
}
Также нам понадобится функция, которая вернет снимок текущего состояния стрика:
suspend fun snapshot(): StreakSnapshot {
val prefs = appCtx.streakDataStore.data.first()
val cur = prefs[KEY_CUR] ?: 0
val best = prefs[KEY_BEST] ?: 0
val last = prefs[KEY_LAST] ?: Long.MIN_VALUE
val set = prefs[KEY_DAYS] ?: emptySet()
val today = LocalDate.now()
val monday = today.with(DayOfWeek.MONDAY)
val week = (0..6).map { i ->
val d = monday.plusDays(i.toLong()).toEpochDay().toString()
set.contains(d)
}
val todayActive = last == today.toEpochDay()
return StreakSnapshot(cur, best, week, todayActive)
}
ViewModel aka VM
Согласно архитектуре MVVM создаем LessonViewModel для координации сценариев экрана. VM будет выбирать очередное число, получать его французское название, передавать все это в UI, получать ответ и отправлять результат в Srs
.
class LessonViewModel : ViewModel() {
var screen by mutableStateOf<Screen>(Screen.Menu); private set
var state by mutableStateOf(QuizState()); private set
var ttsEnabled by mutableStateOf(true); private set
var sessionGoal by mutableIntStateOf(20); private set
private val lastNums: ArrayDeque<Int> = ArrayDeque()
private var customQueue: List<Int>? = null
Первые четыре свойства объявлены с приватным сеттером (private set
), чтобы извне (например, в UI) допускалось только их чтение. В них хранятся:
screen
- текущий экран,state
- состояние урока (вопрос, ответ, статистика по уроку и т.д.),ttsEnabled
- включено ли озвучивание (Text-to-Speech),sessionGoal
- число вопросов в одном уроке.
Все четыре свойства являются состояниями Compose. Они объявлены с помощью делегата by mutableStateOf(...)
/ by mutableIntStateOf(...)
, чтобы любое изменение их значений вызывало рекомпозицию связанных с ними Composable.
Два последних свойства - lastNums
и customQueue
- стоят особняком.lastNums
- коллекция, которая хранит последние выданные числа, чтобы не повторять их сразу, для чего нам и понадобилась двусторонняя очередь ArrayDeque
. customQueue
- настраиваемая очередь чисел для тех случаев, когда мы будем проходить по заранее заданному набору чисел, циклически и в фиксированном порядке, минуя обычный алгоритм отбора, о котором сейчас поговорим.
private fun pickNextNumber(): Int {
customQueue?.let { q ->
if (q.isNotEmpty()) return q[state.totalAsked % q.size]
}
val due = if (Srs.isReady()) Srs.topDueNumbers(5) else emptyList()
val candidates = due.ifEmpty { (1..100).shuffled().take(5) }
val exclude = lastNums.toSet()
val pool = candidates.filterNot { it in exclude }.ifEmpty { candidates }
val chosen = pool.random()
lastNums.addLast(chosen); if (lastNums.size > 6) lastNums.removeFirst()
return chosen
}
Если задана customQueue
, то просто берем следующий элемент, а если он последний в списке, то начинаем сначала (реализовано через остаток от деления).
В общем же случае мы выбираем числа для повторения: прежде всего нас интересуют 5 созревших (dueNums
), а если таковых не нашлось, то берем 5 случайных. Исключаем числа, которые недавно попадались (lastNums
). Заодно обновляем наш кольцевой буфер, в случае переполнения убирая из него самый старый элемент. Наконец, возвращаем одно случайное число из выбранных кандидатов.
Почему мы не храним в памяти отсортированный по due
список, а загружаем и сортируем его при выборе каждого следующего числа? Дело в том, что, как мы скоро увидим, этот список будет меняться после каждого ответа, поэтому нам придется подгружать его каждый раз заново.
Формируя вопрос, мы должны не просто получить следующее пригодное для повторения число, но еще и обеспечить всю сопутствующую информацию, которая будет отображена на экране:
private fun makeQuestion(number: Int): Pair<String, String> {
val fr = FrenchNumbers.toFrench(number)
return number.toString() to fr
}
private fun makeOptions(correct: String): List<String> {
val pool = mutableSetOf(correct)
while (pool.size < 4) pool += FrenchNumbers.toFrench(Random.nextInt(0, 101))
return pool.shuffled()
}
@RequiresApi(Build.VERSION_CODES.O)
fun next() {
if (state.totalAsked >= sessionGoal) screen = Screen.Result
else {
val n = pickNextNumber()
val (q, a) = makeQuestion(n)
val opts = if (state.mode == Mode.MultipleChoice) makeOptions(a) else emptyList()
state = state.copy(
currentNumber = n,
currentQuestion = q,
currentAnswerCanonical = a,
options = opts,
feedback = null
)
}
}
Метод makeQuestion()
обращается к нашему крохотному переводчику за французским названием числа и возвращает пару из числа и этого название. makeOptions()
делает то же самое для еще 3-х случайных чисел, которые вместе с правильным ответом формируют пул из 4-х вариантов ответа. Обратите внимание, что мы используем множество (mutableSetOf
), то есть дублей не будет.
Наконец, в методе next()
мы собираем все полученные данные и записываем их в state
, делая его копию для гарантированного запуска рекомпозиции.
При запуске нам нужно определиться, задан ли список чисел в customQueue
, обнулить состояние и список последних чисел и вызвать метод next()
:
fun start(nums: List<Int>? = null) {
customQueue = nums
lastNums.clear()
state = state.copy(score = 0, totalAsked = 0, feedback = null)
screen = Screen.Quiz
next()
}
При проверке ответа нам лучше игнорировать диакритические знаки, поэтому напишем вспомогательную функцию:
private fun normalize(s: String): String {
val lower = s.lowercase().trim()
val decomposed = java.text.Normalizer.normalize(lower, java.text.Normalizer.Form.NFD)
val noAccents = decomposed.replace(Regex("\\p{Mn}+"), "")
return noAccents.replace(Regex("[\\s\\-’'`]"), "")
}
Теперь можно проверять сам ответ:
fun checkAnswer(answerRaw: String) {
val canonUser = normalize(answerRaw)
val canonCorrect = normalize(state.currentAnswerCanonical)
val ok = canonUser == canonCorrect
Srs.updateNumber(state.currentNumber, ok)
state = state.copy(
score = state.score + if (ok) 1 else 0,
totalAsked = state.totalAsked + 1,
feedback = if (ok) "✔ Правильно: ${state.currentAnswerCanonical}"
else "✘ Неверно. Правильно: ${state.currentAnswerCanonical}"
)
}
В зависимости от результата сравнения ответа пользователя и правильного ответа мы обновляем карточку SRS для текущего числа и state
.
Добавим несколько функций для переключения экранов,
fun toMenu() { screen = Screen.Menu }
fun openStats() { screen = Screen.Stats }
fun openSettings() { screen = Screen.Settings }
изменения режима ввода (выбор варианта или ручной)
fun setMode(m: Mode) { state = state.copy(mode = m) }
и включения/выключения озвучки:
fun toggleTts() { ttsEnabled = !ttsEnabled }
Экраны
Нам достаточно всего пяти экранов:
sealed interface Screen {
data object Home : Screen
data object Quiz : Screen
data object Result : Screen
data object Stats : Screen
data object Settings : Screen
}
Интерфейс Screen
объявлен с модификатором sealed
, чтобы ограничить круг его наследников набором перечисленных экранов.
Главный экран, который появляется при запуске приложения, у нас будет выглядеть примерно так:

Вот код для отрисовки этого экрана:
@Composable
fun HomeScreen(
onStart: () -> Unit,
onStats: () -> Unit,
onSettings: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
MaterialTheme.colorScheme.secondary.copy(alpha = 0.08f)
)
)
)
.statusBarsPadding()
.padding(24.dp)
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.Top),
horizontalAlignment = Alignment.Start
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(Modifier.weight(1f)) {
Text(
"Тренировка числительных",
style = MaterialTheme.typography.headlineLarge
)
Spacer(Modifier.height(6.dp))
Text(
"Учись считать по-французски легко и быстро",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(start = 12.dp)
) {
Text("??", style = MaterialTheme.typography.displaySmall)
Spacer(Modifier.height(4.dp))
Text("1 2 3", style = MaterialTheme.typography.titleLarge)
}
}
}
StreakCard()
Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) {
BigActionButton(
text = "Начать тренировку",
leading = { Icon(Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onStart
)
BigActionButton(
text = "Статистика",
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
onClick = onStats
)
BigActionButton(
text = "Настройки",
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
onClick = onSettings
)
}
}
}
}
В качестве параметров функция получает три лямбды, соответствующие действиям навигации. Как и полагается в архитектуре MVVM, экран ничего не знает о навигации и лишь вызывает переданные функции при нажатиях соответствующих кнопок.
Для корневого контейнера мы задаем в качестве фона диагональный градиент (по умолчанию из верхнего левого к нижнему правому углу). Цвета берем из палитры Material3 (colorScheme.primary/secondary
) и делаем полупрозрачными (copy(alpha=0.08f)
), чтобы получилась легкая дымка.
А вот та самая большая кнопка:
@Composable
private fun BigActionButton(
text: String,
leading: @Composable (() -> Unit)? = null,
onClick: () -> Unit
) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
contentPadding = PaddingValues(vertical = 18.dp, horizontal = 20.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp)
) {
if (leading != null) {
leading()
Spacer(Modifier.width(12.dp))
}
Text(text, style = MaterialTheme.typography.headlineSmall)
}
}
Что самое главное на этом экране? Конечно же, ударный режим!
@Composable
fun StreakCard(
modifier: Modifier = Modifier
) {
var snapshot by remember { mutableStateOf(StreakSnapshot(0, 0, List(7) { false }, false)) }
LaunchedEffect(Unit) {
snapshot = Streak.snapshot()
}
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
)
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Text("⚡", style = MaterialTheme.typography.titleLarge)
Column {
Text("Дней без перерыва: ${snapshot.current}", style = MaterialTheme.typography.titleLarge)
Text("Рекорд — ${snapshot.best}", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
val labels = listOf("пн","вт","ср","чт","пт","сб","вс")
snapshot.week.forEachIndexed { i, done ->
Box(
modifier = Modifier
.size(22.dp)
.background(
color = if (done) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(6.dp)
),
contentAlignment = Alignment.Center
) {
Text(labels[i], style = MaterialTheme.typography.labelSmall, color = if (done) Color.White else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
В переменной snapshot
мы храним локальное состояние карточки. Его изменение вызовет рекомпозицию.
LaunchedEffect
позволяет запустить корутину, которая автоматически привязывается к жизненному циклу текущей Composable-функции. Это ровно то, что нам нужно в данном случае, потому что данные об ударном режиме достаточно обновлять при каждом создании стартового экрана. Они не нуждаются в более частом обновлении, потому что ударный режим не обновишь, пока не зайдешь в упражнение и не вернешься из него, а к этому времени мы уже пересоздадим стартовый экран.
Теперь посмотрим на экран с упражнением.

В верхней части мы просто выводим прогресс упражнения в формате "x из y" и количество правильных ответов:
@Composable
private fun QuizHeader(
currentIndex: Int,
totalQuestions: Int,
score: Int
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp, start = 4.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Левая часть: иконка + прогресс "x из y"
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f)
)
Spacer(Modifier.width(6.dp))
Text(
"$currentIndex из $totalQuestions",
style = MaterialTheme.typography.titleMedium
)
}
// Правая часть: очки + нота
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.Star,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.85f)
)
Spacer(Modifier.width(6.dp))
Text("$score", style = MaterialTheme.typography.titleMedium)
}
}
}
При этом currentIndex
начинаем с 1, а не с 0, чтобы он был готов для вывода.
Основная часть кода экрана длинная, но довольно простая:
@Composable
fun QuizScreen(
state: QuizState,
totalQuestions: Int,
ttsEnabled: Boolean,
onAnswer: (String) -> Unit,
onNext: () -> Unit,
onSpeak: (String) -> Unit
) {
val focus = LocalFocusManager.current
var input by remember { mutableStateOf("") }
var selectedAnswer by remember { mutableStateOf<String?>(null) }
// Сброс при новом вопросе
LaunchedEffect(state.currentQuestion) {
input = ""
selectedAnswer = null
}
// Автопереход после показа фидбэка
LaunchedEffect(state.totalAsked) {
if (state.feedback != null) {
val baseDelay = 1500L
val extraDelay = if (ttsEnabled)
(state.currentAnswerCanonical.length * 40L).coerceAtMost(2000L)
else 0L
delay(baseDelay + extraDelay)
onNext()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Подзаголовок: вопрос / очки / звук
QuizHeader(
currentIndex = state.totalAsked + if (state.feedback == null) 1 else 0,
totalQuestions = totalQuestions,
score = state.score
)
// Карточка задания
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f)
),
shape = RoundedCornerShape(20.dp)
) {
Column(
Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Как будет по-французски?", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
Text(state.currentQuestion, style = MaterialTheme.typography.displayLarge)
}
}
// Режим выбора
when (state.mode) {
Mode.MultipleChoice -> {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
state.options.forEach { opt ->
val isSelected = selectedAnswer == opt
val isCorrectOption = opt == state.currentAnswerCanonical
val isCorrectSelected = state.feedback != null && isSelected && isCorrectOption
val isWrongSelected = state.feedback != null && isSelected && !isCorrectOption
val isCorrectUnselected = state.feedback != null && !isSelected && isCorrectOption
Button(
onClick = {
if (state.feedback == null) {
selectedAnswer = opt
onSpeak(opt)
onAnswer(opt)
}
},
enabled = state.feedback == null,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(28.dp),
colors = ButtonDefaults.buttonColors(
containerColor = when {
isCorrectSelected || isCorrectUnselected -> Color(0xFF4CAF50)
isWrongSelected -> Color(0xFFF44336)
else -> MaterialTheme.colorScheme.primary
},
disabledContainerColor = when {
isCorrectSelected || isCorrectUnselected -> Color(0xFF4CAF50)
isWrongSelected -> Color(0xFFF44336)
else -> MaterialTheme.colorScheme.primary
}
),
contentPadding = PaddingValues(horizontal = 20.dp)
) {
Text(opt, style = MaterialTheme.typography.titleLarge)
}
}
}
}
Mode.Typing -> {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Введите ответ (например, vingt et un)") },
enabled = state.feedback == null,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
focus.clearFocus()
if (state.feedback == null) onAnswer(input)
})
)
}
}
// Фидбэк
if (state.feedback != null) {
Text(
state.feedback,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.fillMaxWidth()
)
}
}
}
Здесь оба LaunchedEffect
запускают корутины, привязанные к жизненному циклу этой Composable-функции. При этом у них указаны ключи, в отличие от предыдущего примера, где в качестве параметра мы передали Unit
. При смене ключа корутины будут перезапущены.
LaunchedEffect(state.currentQuestion) {
input = ""
selectedAnswer = null
}
-- этот код перезапускает корутину, обнуляя ввод и выбранный ответ, при приходе нового вопроса.
LaunchedEffect(state.totalAsked) {
if (state.feedback != null) {
val baseDelay = 1500L
val extraDelay = if (ttsEnabled)
(state.currentAnswerCanonical.length * 40L).coerceAtMost(2000L)
else 0L
delay(baseDelay + extraDelay)
onNext()
}
}
-- здесь мы включаем таймер, чтобы перейти к следующему вопросу автоматически, а не нажимать кнопку. При включенной озвучке даем пользователю дополнительное время, чтобы услышать текст (продолжительность extraDelay
зависит от длины читаемого текста). Таймер запускается при изменении счетчика state.totalAsked
, то есть после каждого ответа. После delay()
мы вызываем метод onNext()
, который нам еще предстоит определить.
Остальные три экрана - результат упражнения, настройки и статистика - уже не относятся собственно к SRS-тренажеру, поэтому не будем останавливаться на их коде. Кнопка "Потренировать слабые" запускает обычный урок, но с передачей фиксированного списка в метод VM start()
, который мы уже обсудили. Список берем отсюда же, из этой тепловой карты, отсортировав по какой-нибудь эмпирической метрике и взяв первые n
значений.

MainActivity
Осталось собрать все вместе. В главной активити важно не забыть проинициализировать движок TTS в методе onCreate()
и выключить его в методе onDestroy()
:
class MainActivity : ComponentActivity() {
private var tts: TextToSpeech? = null
@RequiresApi(Build.VERSION_CODES.O)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
lifecycleScope.launch { Srs.init(applicationContext) }
tts = TextToSpeech(this) { status ->
if (status == TextToSpeech.SUCCESS) tts?.language = Locale.FRANCE
}
Streak.init(applicationContext)
setContent { App(tts = tts) }
}
override fun onDestroy() {
tts?.stop()
tts?.shutdown()
super.onDestroy()
}
}
Здесь включена поддержка модного режима edge-to-edge, то есть область отрисовки будет расширена на всю область экрана.
А теперь мы отрисовываем текущий экран в зависимости от значения свойства screen
из VM, пробрасывая в этот экран соответствующие параметры из той же VM. Паттерн MVVM в действии!
Как и все Composable-функции, наша функция App() длинная, но простая:
@Composable
fun App(tts: TextToSpeech?, vm: LessonViewModel = viewModel()) {
MaterialTheme(colorScheme = lightColorScheme()) {
Scaffold(
topBar = {
if (vm.screen != Screen.Home) {
TopAppBar(
title = {
if (vm.screen != Screen.Home) {
Text(
when (vm.screen) {
Screen.Quiz -> "Тренировка"
Screen.Result -> "Результат"
Screen.Stats -> "Статистика по числам"
Screen.Settings -> "Настройки"
else -> ""
},
style = MaterialTheme.typography.titleLarge
)
}
},
navigationIcon = {
IconButton(onClick = { vm.toMenu() }) {
Icon(Icons.Filled.ArrowBack, contentDescription = "В меню")
}
},
actions = {
if (vm.screen == Screen.Quiz) {
// Переключатель TTS в шапке
FilledTonalIconButton(
onClick = { vm.toggleTts() },
modifier = Modifier.size(44.dp)
) {
val icon =
if (vm.ttsEnabled) R.drawable.sound_on else R.drawable.sound_off
Icon(
painter = painterResource(icon),
contentDescription = if (vm.ttsEnabled) "Озвучка включена" else "Озвучка выключена",
modifier = Modifier.size(26.dp)
)
}
}
}
)
}
},
contentWindowInsets = WindowInsets(0)
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when (vm.screen) {
Screen.Home -> HomeScreen(
onStart = { vm.start() },
onStats = { vm.openStats() },
onSettings = { vm.openSettings() }
)
Screen.Quiz -> QuizScreen(
state = vm.state,
totalQuestions = vm.sessionGoal,
ttsEnabled = vm.ttsEnabled,
onAnswer = { vm.checkAnswer(it) },
onNext = { vm.next() },
onSpeak = { text -> if (vm.ttsEnabled) tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "ans") }
)
Screen.Result -> ResultScreen(
score = vm.state.score,
total = vm.state.totalAsked,
onRestart = { vm.toMenu() }
)
Screen.Stats -> StatsScreen(
onBack = { vm.toMenu() },
onPracticeWeak = { weakList -> vm.start(weakList) }
)
Screen.Settings -> SettingsScreen(
mode = vm.state.mode,
onModeChange = { vm.setMode(it) },
ttsEnabled = vm.ttsEnabled,
onToggleTts = { vm.toggleTts() },
sessionGoal = vm.sessionGoal,
onSessionGoalChange = vm::changeSessionGoal
)
}
}
}
}
}
Переключатель озвучки мне показалось удобным держать в TopAppBar
, чтобы он был на самом виду.
Также в этом коде был задан параметр contentWindowInsets
, чтобы наш градиентный фон не перекрывался инсетом Scaffold
'а.
contentWindowInsets = WindowInsets(0)
Итоги
Приложение можно скачать в Google Play.
Изучение иностранных языков с помощью SRS-приложений - мой конек. И учить языки, и писать подобные тренажеры я могу до бесконечности. Вот еще один пример, о котором я когда-то рассказывала на Хабре и который затем обновила на Jetpack Compose.
А если вы хотите лучше разобраться в корутинах и Flow, то у меня есть подробный теоретический курс на Степике по этой теме.