Welcome back to part 3 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 9-12.
In case you haven’t read the first two articles, you can find them here:
- Building a Language Learning App with Compose – Part 1
- Building a Language Learning App with Compose – Part 2
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.
Day 9 [Code Diff]
Now that we already have decks, it makes sense to list them on the home screen. The user should only see decks they’re subscribed to but given that we don’t have an user nor can subscribe to decks, I’m just listing everything.
I just repeated the use case and repository changes you’ve seen me do before and called it from the view model.
One thing worth mentioning is that that I’m not returning the Deck
entity we created earlier. Let me first explain problem I had so I can explain why I did that.
Let’s pretend there’s a deck that has 10 words. User A subscribes to that deck and learns 3 words, User B also subscribes to that deck and learns 1 word. We need somehow to store that these different users learned different words, they can’t be part of the Deck
. To overcome this problem I created the MyDeck
entity(and later MyCard
). These 2 entities are representations of Deck
or DeckCard
that belong to a user.
data class MyDeck(
val id: String, // different from deckId, later I added the deckId too
val title: String,
val learnedCards: Int,
val totalCards: Int,
)
Right now there’s no userId
field here because I still haven’t built that, nonetheless, that’ll happen in the future.
What you can see below is a List<MyDeck>
, I’m not sure if the My prefix is a good choice, I was also thinking of SubscribedDeck
but I’m not sure how this project will evolve, so I’ll keep using the prefix for now.
I also started creating what will become the practice screen.
Day 10 [Code Diff]
On day 10, I started working on the practice screen, probably the most important screen of the app. This is the screen where users learn new words and review the already learned ones. This is also where people will spend most of their time in the app.
I’m calling it “Practice” but in Doulingo it’s called “Lesson”. When practicing you’re either reviewing the words you’ve already learned or learning new words.
Let’s say there’s a deck called “10 Italian Words”, the first time you practice it, you’re gonna get let’s say 5 new words. The next time you practice it, you’re going to get 3 new words and 5 words to review.
Right now I’ll only be using words, so it’s going to work like this: I’m going to display a word like “Ape” and the user has to type “Bee”. When the user submits their answer we need to check it.
Initially it’ll work like this :
- There are some characters that will be ignored, for example: question marks, commas, exclamation marks, … “Bee” and “Bee?” are both correct answer.
- If the user types “Be” I also want to be a correct answer because it’s pretty similar. I’m not sure how similar yet but probably 85-90% of the characters.
- Everything else is a wrong answer.
Basically if the user types something that’s similar to the answer, It’ll be considered a correct answer. In my experience learning new languages, I don’t like when I mistype something and have to start all over so I won’t be doing the same with Lingua.
I’m calling this a practice session. When the user opens the practice screen, the backend will return an object containing the words to be learned/reviewed, initially it’s going to be max 8 words.
There are three ways to check an answer:
- Return the possible answers from the backend and the client just checks if any of them matches. This doesn’t work because there’s simply dozens of options as I’ve shown before.
- Send the answer to the backend. This would be great but it’d add the latency of calling the backend service and this is something that would cause a bad experience for the user.
- Checking the answer locally. This is not the ideal option but at least we can give feedback to the user near instantaneously, this is the approach I chose.
For now I simply created a use case for that and returned true, this will work for now.
class CheckPracticeAnswerUseCaseImpl @Inject constructor() : CheckPracticeAnswerUseCase {
override fun checkAnswer(card: DeckCard, answer: String): Boolean {
return true
}
}
I continued creating the practice screen and changed the view model so it requests a practice session from the backend.
I wanted to animate the progress bar, to do that I used animateFloatAsState
. In the state class I have a progress variable that just reflects the progress of the practice.
val progress by animateFloatAsState(targetValue = state.progress)
LinearProgressIndicator(
progress = progress,
modifier = Modifier.fillMaxWidth()
)
The values in the image above are still hard coded, it’s only on day 12 that I actually started calling the backend.
Day 11 [Code Diff]
As I mentioned earlier I created something called MyCard
. This is the entity that stores information about cards you practiced. There’s the cardId
so we know which card this refers to and a list of the times you practice this card. This is useful to know when to review the card again and to show in charts later.
data class MyCard(
val id: String,
val cardId: String,
val practices: List<MyCardPractice>,
)
data class MyCardPractice(
val date: Date,
val isCorrect: Boolean
)
I also created an use case that’s called after you review a card so the practices
fields is updated.
interface PracticeCardUseCase {
suspend fun update(cardId: String, isCorrect: Boolean)
}
In the view model, I first call the check answer use case and then the use case to update the card,
fun checkAnswer() {
val isCorrect = checkPracticeAnswerUseCase.checkAnswer(card, state.answer)
// We don't want to block the practice, just fire and forget
viewModelScope.launch {
runCatching { practiceCardUseCase.update(card.id, isCorrect) }
.onFailure { /*todo: show non-interruptive error */ }
}
state.progress = 1f - (1f / session.cards.size * cardsLeft.size)
loadNextQuestion()
}
I’m not using a normal toolbar, I custom built one so when I loaded decks that had long titles my toolbar was spanning 2 lines and that’s not ideal. To fix that I made very small changes to my Text
component in the toolbar.
Text(
text = title,
fontSize = 20.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Day 12 [Code Diff]
On day 10, I explained what a practice is, on day 12 I started creating the class to return a PracticeSession
. The PracticeSession
should come from the backend but given that I don’t have a backend yet, it’s being created on the client.
To create a practice session I start by passing the Deck
to be reviewed and all MyCard
s. The MyCard
let me know which cards have already been reviewed.
availableMyCards
contains only the cards that belong to the given deck, I map these cards to their next review date.- If the card has never been reviewed, it should be reviewed as early possible. That’s why I return
Date(0)
. Then I check if the last practice was correct, if it wasn’t then the card should be reviewed 8hrs after the last practice. If the last practice was correct then I’m just using a random interval to choose when to review it again. - After I have all the cards and their respective review dates, I filter all the ones that have the review date before now, meaning they should be reviewed again. Finally I get the first 8 sorted by review time.
fun create(deck: Deck, myCards: List<MyCard>): PracticeSession? {
val availableMyCards = deck.cards // #1
.map { card ->
card to myCards.find { it.cardId == card.id }
}
.mapToNextReviewDate()
val now = Date()
val sortedCards = availableMyCards // #3
.filter { (_, review) -> review <= now }
.sortedBy { (_, review) -> review.time }
.map { it.first }
.take(8)
if (sortedCards.isEmpty()) return null
return PracticeSession(
id = UUID.randomUUID().toString(),
title = deck.title,
cards = sortedCards
)
}
private fun List<Pair<DeckCard, MyCard?>>.mapToNextReviewDate(): List<Pair<DeckCard, Date>> {
return map { (card, myCard) -> card to getNextReviewDate(card, myCard) }
}
// #2
fun getNextReviewDate(card: DeckCard, myCard: MyCard?): Date {
// If card has never been reviewed, review it now
if (myCard == null || myCard.practices.isEmpty()) return Date(0)
val practices = myCard.practices.sortedBy { it.date }
// If user got last answer wrong, review 8hrs after practice
val lastPractice = practices.last()
if (!lastPractice.isCorrect) return lastPractice.date.plusHours(8)
// If user got last answer right, just review in a few hours. This is simplified for now
return Date().plusHours(Random.nextInt(12, 40))
}
The gif below shows a real practice being returned and displayed to the user.
With these updates, we come to the end of part 3. Each day that goes by we come closer to a functional app.
If you have any comments or suggestions, please reach out to me on Twitter.
Stay tuned for the next updates.
Photo by Immo Wegmann on Unsplash