Skip to content

Building a Language Learning App with Compose – Part 5

Welcome to part 5 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 18-20.

In case you haven’t read the other 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.

In this article, I’ll talk about a new practice mode I added and also about the initial migration to use Room as a database.

Day 18 [Code Diff]

Besides the 2 decks I already had, I created 2 new ones so I have more words to review.

When I first wrote the CheckPracticeAnswerUseCaseImpl, I was checking if the difference was smaller than 4 characters. That didn’t work very well so I changed it to be 20% of the correct answer length. In a 5 characters word, only 1 character can be off.

In part 1, I showed you a prototype of the app including the screen below. It’s another way to practice cards. Instead of typing the answer, the user has to pick the right answer.

This is a useful functionality so I decided implement it. The existing code only supported the case where the user types something, for new case I modified the code so it’s extensible and also allows other cases in the future.

I was not sure how to implement this, so I decided to get something working first and then refactor it. The implementation I’ll show below is messy but that’s okay because on day 19 I went over it and refactored this code.

This is how the PracticeSession looked like, it only returns a list of cards to be reviewed. I had to change it to also include how the card should be reviewed.

data class PracticeSession(
    val id: String,
    val title: String,
    val cards: List<DeckCard>
)

To do that I created the PracticeType interface, the implementations define how the card should be reviewed and also have additional information, for example, I need a list of options to show when the multiple options case is displayed to the user. All types have a DeckCard because that’s what is used to check the answer.

sealed interface PracticeType {
    val card: DeckCard

    data class TypeAnswer(override val card: DeckCard) : PracticeType
    data class MultipleOption(override val card: DeckCard, val options: List<String>) : PracticeType
}

After that, I had to change the PracticeSessionCreator because I had changed the PracticeSession class.

I set MULTIPLE_OPTION_PROBABILITY to 30, meaning there’s a 30% chance that a card will be reviewed using the multiple option type. I only proceed if the card outputs are 1 word only. I create validOptions that only contains the possible answers excluding the right answer. After that I just create the CardPractice.MultipleTextOptions using 3 possible answer + the right answer.

if (Random.nextInt(1..100) <= MULTIPLE_OPTION_PROBABILITY) {
    if (card.outputs.none { it.contains(" ") }) {
        val validOptions = sortedCards
            .minus(card) // remove the answer possibly from options
            .filter { it.outputs.none { output -> output.contains(" ") } }

        if (validOptions.size >= MULTIPLE_OPTION_COUNT - 1) {
            return CardPractice.MultipleTextOptions(
                card = card,
                options = validOptions.shuffled()
                    .take(MULTIPLE_OPTION_COUNT - 1)
                    .map { it.outputs.random() }
                    .plus(card.outputs.random()) // add answer to options
                    .shuffled()
            )
        }
    }
}

Here’s how the multiple options type ended up looking on the first day:

Progress by the end of day 18

Day 19 [Code Diff]

On day 19, I didn’t add any new feature. I just refactored the code I wrote on day 18.

I renamed PracticeType to CardPractice. I also renamed TypeAnswer to InputField and MultipleOption to MultipleTextOptions. I think this makes the code easier to understand.

I also made a small change to the card so it’d look a little bit more beautiful.

Progress by the end of day 19

Day 20 [Code Diff]

I want to start using this app to learn languages but at the moment it’s very hard given it only stores things in memory. To be able to use the app we need either to store things locally or create the backend service.

I chose to add a local database because I’ll use it for caching later anyway. I’m using Room as the database.

The initial database looks like this:

@Database(
    entities = [
        DeckData::class,
        DeckCardData::class
    ], version = 1
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun deckDao(): DeckDao
    abstract fun deckCardDao(): DeckCardDao
}

Let me explain the architecture I’m using for this part of the project.

First of all, all code related to Room stays in the repository implementation layer. I don’t want to leak it into other layers of the projects. The benefit of doing this is that I was able to migrate my project from using an in-memory list to a database by just changing the data layer. No need to change any use case or view model because of that. If in the future I choose to use a backend service, I’ll only have to change the data layer. That’s the beauty of abstracting your layers.

Inside the repository implementation package there are 3 other packages:

  • data this is where I define the database entities. For example I created DeckData and DackCardData, some people use the Entity sufix instead but it’s the same thing.
  • mapper this is where the mapper lives. It maps from the database entity to a domain entity and from a domain entity to a database entity. This is done so the database classes don’t leak into other layers of the app.
  • dao this is where the DAO is implemented. It’s the class Room uses to define a database table.

Adding all these classes certainly increases the complexity of the project but for me it’s a worth trade off.

Let’s use DeckCard as example. I first created a Dao for it.

@Dao
interface DeckCardDao {
    @Query("SELECT * FROM deck_card WHERE id = :id LIMIT 1")
    suspend fun getById(id: String): DeckCardData?

    @Query("SELECT * FROM deck_card WHERE deck_id = :id")
    suspend fun getByDeckId(id: String): List<DeckCardData>

    @Insert
    suspend fun insert(deckCardData: DeckCardData)
}

After that I created its entity. I set a foreign key on its deck so it’s deleted in case the deck is deleted.

@Entity(
    tableName = "deck_card",
    foreignKeys = [
        ForeignKey(
            DeckData::class,
            parentColumns = ["id"],
            childColumns = ["deck_id"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class DeckCardData(
    @PrimaryKey val id: String,
    @ColumnInfo(name = "deck_id") val deckId: String,
    @ColumnInfo(name = "input") val input: String?
)

You might notice that the outputs are missing and that’s because Room doesn’t allow us saving lists. I’ll come back to that in part 6.

Finally I created a mapper for it.

class DeckCardDataMapper @Inject constructor() {

    fun toData(card: DeckCard): DeckCardData {
        return DeckCardData(
            id = card.id,
            deckId = card.deckId,
            input = card.input
        )
    }

    fun fromData(card: DeckCardData): DeckCard? {
        return DeckCard(
            id = card.id,
            deckId = card.deckId,
            input = card.input ?: return null,
            outputs = emptyList() // <--- empty list for now
        )
    }
}

I did the same things for other entities, you can check the code diff if you want to see it.


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

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

Stay tuned for the next updates.

Photo by CHUTTERSNAP on Unsplash