Skip to content

Building a Language Learning App with Compose – Part 4

Welcome to part 4 of “Building a Language Learning App with Compose ” series. This is a series where I’ll be sharing my progress building a language learning app.

In this series, I’ll be sharing my progress, design choices, and any new insights I gain along the way. Today I’ll be covering days 13-16.

In case you haven’t read the first three articles, you can find them here:

If you want to know exactly which code changes I made, you can click the [Code Diff] link besides the day header and see all the changes for that day.

It takes a lot of time to write these articles, from now on I’ll continue describing everything I did but will only explain some things. If you want to understand how I did something I didn’t explain, you can just take a look at the [Code Diff].

Day 13 [Code Diff]

I started day 13 by modifying the deck card so that when it’s pressed, it navigates to the practice for the corresponding deck.

I also added icons to the deck card to show how many cards you have to review(left) and how many cards you’ve already learned(right).

Not a big change but pretty useful information for the user.

Progress by the end of day 13

Day 14 [Code Diff]

On day 14, I removed all the fake decks I had and created 2 with real words.”20 Most Popular Italian Nouns” and “Words for Italian Tourists”. In the future there will be hundreds of decks but for now these 2 will at least make the practice look more real.

I also added some code to show a Toast if the user presses a deck card that has no cards to review. To do that I added a new action to my MyDeckListAction.

sealed interface MyDeckListAction {
    ...
    object ShowNoCardsToReviewInfo : MyDeckListAction
}

Then in the method that navigates to the practice I check if there are words to review.

viewModelScope.launch {
      val action =
             if (model.cardsToReview <= 0) MyDeckListAction.ShowNoCardsToReviewInfo
             else MyDeckListAction.NavigateToPractice(model.deckId)
  
      _action.emit(action)
}

I also removed the “Practice” button from the home screen, I won’t be implementing it in the near future so there’s no value in it being there.

Progress by the end of day 14

Day 15 [Code Diff]

I started day 15 by implementing one of the most important classes of this project, the class that checks whether an answer is correct.

I need to know a little bit more than just if the answer is correct/wrong. If it’s correct I also need to know if it’s an exact match. To do that I created a new class that represents the possible results of the use case.

sealed interface CheckPracticeAnswerResponse {
    data class Correct(val isExactAnswer: Boolean) : CheckPracticeAnswerResponse
    object Wrong : CheckPracticeAnswerResponse
    
    fun isCorrect() = this is Correct
}

After that I defined a list of characters that I want to ignore when checking the answer. That means “How?” and “How” are treated as if they were the same thing.

private val charsToIgnore = Regex("[?!,.;\"']")

Now we come to the code that checks the answer. I start by normalizing the typed answer and the possible answers(outputs), that’s just removing the characters to ignore.

I then compare the normalized typed answer to the normalized possible answers, if any of them matches then the user typed the right thing, otherwise it’s the wrong answer.

override fun checkAnswer(card: DeckCard, answer: String): CheckPracticeAnswerResponse {
    val normalizedAnswer = answer.normalize()
    val normalizedOutputs = card.outputs.normalize()

    if (normalizedAnswer.isBlank())
        return Wrong
    if (normalizedOutputs.any { it == normalizedAnswer })
        return Correct(isExactAnswer = card.outputs.any { it == answer })

    return Wrong
}

private fun String.normalize() = trim().replace(charsToIgnore, "")
private fun List<String>.normalize() = map { it.normalize() }

This is still not the final version of this class but it’s good enough for now.

Because this class has logic that’s a little bit more complicated than the rest of the application, I decided to write some unit tests for it. In this case I think it’ll be worth my time. Here’s an example of a test:

private val card1 = card(
    input = "Potrebbe aiutarmi, per favore?",
    outputs = listOf("Could you help me, please?"),
)

@Test
fun answerWithoutQuestionMarkIsCorrect() {
    val answer = "Could you help me, please"

    val result = useCase.checkAnswer(card1, answer)

    assertEquals(Correct(isExactAnswer = true), result)
}

I also modified my PracticeViewModel to display the answer in the question so it was easier for me to test this feature.

The last thing I did was adding a container to the bottom of the screen. It appears when you type something that’s not exactly the correct answer.

  • It’s a red box if you get the answer wrong
  • It’s a green box if your answer is pretty close the the actual answer

The default transition for AnimatedVisibility expands/collapses the container vertically and horizontally but I only want it to expand/collapse vertically so I had to change the enter/exit transition.

@Composable
private fun InfoBox(state: PracticeState) {
    AnimatedVisibility(
        visible = state.infoText != null,
        enter = fadeIn() + expandIn(initialSize = { IntSize(it.width, 0) }),
        exit = shrinkOut(targetSize = { IntSize(it.width, 0) }) + fadeOut()
    ) {
        val bgColor = state.infoBackgroundColorRes ?: return@AnimatedVisibility

        Box(
            modifier = Modifier
                .fillMaxWidth()
                .background(colorResource(id = bgColor))
                .padding(horizontal = 12.dp, vertical = 20.dp)
        ) {
               ....
        }
    }
}

After 15 days, we finally have the first “usable” version of the app.

Progress by the end of day 15

Day 16 [Code Diff]

If the user types the wrong answer or something that’s not exactly the answer, I show a box in the bottom with the right answer. When that happens I don’t want the input to be enabled so I need someway to disable the input.

I also want to be able go to the next question using the ENTER key on my keyboard, I’ll be using this mostly on an emulator so it’s a useful feature for me.

I don’t actually disable the field, I just ignore the new values if they input is not supposed to receive new characters.

fun onAnswerChanged(answer: String) {
    if (!isAnswerFieldEnabled()) return
    state.answer = answer
}

To continue to the next question when the ENTER key is pressed I had to add the onKeyEvent modifier to the answer input.

TextField(
    ...
    modifier = Modifier
        .onKeyEvent { onKeyEvent(it.nativeKeyEvent) }
)

If the key event is different from ENTER I just ignore it, otherwise, I emit Unit to a channel.

fun onKeyEvent(keyEvent: NativeKeyEvent): Boolean {
    if (keyEvent.keyCode != KeyEvent.KEYCODE_ENTER) return false

    enterEventChannel.tryEmit(Unit)
    return true
}

The reason I added the channel is to prevent multiple presses to the ENTER key in a short amount of time. If you take a look below I’m debouncing the enter key event by 50ms to prevent that from happening. Initially I didn’t have this channel but after some testing I realized that the ENTER key event was causing some problems and this was the reason.

private val enterEventChannel = MutableSharedFlow<Any>(
    extraBufferCapacity = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)

viewModelScope.launch {
    enterEventChannel
        .debounce(KEY_INPUT_DEBOUNCE_DELAY)
        .collect { onContinue() }
}

Finally on the onContinue method I either load the next question or check the answer based in which state the screen is on.

fun onContinue() {
    if (state.infoText != null) {
        loadNextQuestion()
    } else {
        checkAnswer()
    }
}

The practice screen became a little complex and I need to refactor it but that’s something for later.

The home screen was looking too simple so I decided to add some random background colors my the deck cards. The logic is very simple so the colors may repeated, which is not idea, but it’s okay for now.

Up until now the checker algorithm is only checking if the words are equal ignoring some characters. On this day I modified the algorithm again to take into consideration similarity.

For example if the answer is “tastiera” and the user types “tastier” or “astiera”, I want these words to be considered correct.

There are certainly better ways to do this but an easy way is by using the Levenshtein Distance. It’s an algorithm that “measures the difference between two sequences“. It’s not a complex algorithm, this article should give you a good understanding of it.

I didn’t implement the algorithm myself, I found a Java version and converted it to Kotlin. I wrapped it in the WordDistanceCalculator class.

class CheckPracticeAnswerUseCaseImpl @Inject constructor(
    private val wordDistanceCalculator: WordDistanceCalculator
)

For each output, I calculate the distance. I then multiply the word length by a threshold I defined, it’s currently 0.2. That means the difference can be at most 20% of the word length, in a 5 characters word, it can be 1 character. If the distance is smaller or equal to the threshold I return that the answer is correct but I set isExactAnswer to false so I know the user typed something similar to but not exactly the answer.

normalizedOutputs.forEach { output ->
    val distance = wordDistanceCalculator.calculate(normalizedAnswer, output)
    val threshold = (output.length * WORD_DIFF_THRESHOLD).toInt()
    if (distance <= threshold) return Correct(isExactAnswer = false)
}

This algorithm is not perfect but I don’t see myself changing it anymore in the near future, this is the algorithm I had in mind when I described how the practice was going to work.

Progress by the end of day 16

This is also the day I felt like quitting. I was not sure how to proceed with the project so it seemed like I had no motivation to continue working on it. This is something pretty common to me, it has happened multiple times before and is usually what kills my projects.

A few months ago, I read “The Unpleasant Essentials” by Josh Pigford, in the article he says:

Not everything you work on will be enjoyable. Many of the core elements of a project will actually be the hardest parts that you avoid like the plague.

These “unpleasant essentials” are responsible for deaths of an infinite number of projects. They’re the things we procrastinate in to infinity.

When I read this the first time, I became amazed. This had happened multiple times to me before but I never understood why.

He continues by providing the solution:

The key to overcoming these unpleasant essentials isn’t willpower, it’s planning.

It’s deciding “this is where I want this project to end at” and then working your way backwards, step-by-step, to figure out what needs to get done. You’re thinking “here are the steps to get to where I want to be”.

The goal isn’t to avoid unpleasantness, it’s to reduce decision fatigue. You front load all the major decisions and then simply follow the plan.

What was missing for me was “this is where I want this project to end at”. Even though I outlined a vision at the start of the project, I stopped paying attention to it and became lost. The next steps were not clear.


With these updates, we come to the end of part 4.

If you have any comments or suggestions, please reach out to me on Twitter.

Stay tuned for the next updates.

Photo by William Warby on Unsplash