Welcome back to part 2 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 4-8 and sharing some things I learned since the last update.
You can find the other articles of the series here:
- Building a Language Learning App with Compose – Part 1
- Building a Language Learning App with Compose – Part 3
- Building a Language Learning App with Compose – Part 4
From now on I’ll also be adding the code diff for each day. You’ll be able to see exactly what code I changed and what the changes I made were. Right next to the day header, you can find a [Code Diff] link that will take you to GitHub.
That means I’ll also sometimes commit unfinished work for the diff to be correct. I was not doing that so the code diff for this part is not 100% correct but it’ll be for the next ones.
Things I learned
As I said in the first part, this series is not only for me to share my progress but also to share things I learn along the way. Since the last update, I’ve learned I few things I’d like to share before we start with the daily updates.
Navigation
The first change is related to the EditCardRoute
navigation. Previously I had 2 nullable parameters(deckId and cardId) in the route method, but that didn’t seem right. If there are only 2 parameters and one of them will always be present, there has to be a better way to represent states like this. So what I did was create a sealed interface that only contains the 2 possible values.
sealed interface EditCardNavigationParams {
data class AddCard(val deckId: String) : EditCardNavigationParams
data class EditCard(val cardId: String) : EditCardNavigationParams
}
This way I don’t need to handle the case where both of them are null in the ViewModel
, that simply isn’t a valid case anymore. There are many talks about this but it’s basically about removing invalid cases/states, just this change simplified the ViewModel
by a lot.
// Before
fun load(deckId: String?, cardId: String?) {
when {
deckId == null && cardId == null -> {
Logger.e("deckId and cardId are null")
viewModelScope.launch { _action.emit(EditCardAction.NavigateUp) }
return
}
deckId != null -> createNewCard(deckId)
cardId != null -> loadCard(cardId)
}
}
// After
fun load(params: EditCardNavigationParams) {
when (params) {
is EditCardNavigationParams.AddCard -> createNewCard(params.deckId)
is EditCardNavigationParams.EditCard -> loadCard(params.cardId)
}
}
The second change I made was in the router class. Given that the navigation framework isn’t typed, there’s a chance that the parameter I want won’t be there, so its type is nullable. However in my case, if the right method is used to create a route that won’t happen. So in the code where I parse the parameter and launch the route, I added a requireNotNull
to the value returned by the route parser. That way if I ever make the mistake of using it incorrectly, the app will crash and I’ll know there’s a problem.
composable(Routes.AddCard) {
val deckId = requireNotNull(Routes.AddCard.parse(it.arguments)) { "deckId is missing" }
EditCardRoute(navController, params = EditCardNavigationParams.AddCard(deckId))
}
This is known as failing as early as possible, there are also many talks about this if you’re interested.
The third and final change is a small one, I simply replaced my sealed classes
with sealed interfaces
. In my case there was no need for them to be classes so I just changed it. If you want to learn more about them, there’s great this article about it.
sealed interface EditDeckAction {
data class NavigateToAddCard(val deckId: String) : EditDeckAction
data class NavigateToEditCard(val cardId: String) : EditDeckAction
object NavigateUp : EditDeckAction
}
This I how I usually do navigation in the ViewModel
, just a sealed interface
with the possible actions the view(in this case the composable) should be able to execute.
These are the main changes I made, now let’s jump into to the daily updates.
Day 5 [Code Diff]
On day 5 I stopped working on the UI to start working on the logic of the application. I think now it’s a good time for me to explain a bit about the architecture I’ll be using in the project.
It’s a layered architecture with 3 layers:
- UI layer: This is where all the UI code stays, basically everything related to Jetpack Compose, the components, the view models, the screens, and so on.
- Business logic layer: In this application, this will mostly be use cases.
- Data/Infrastructure layer: This is where the repositories and mappers stay but also other infrastructure classes such as things related to databases and connectivity.
I’m using Hilt for dependency injection but I don’t consider that to be a layer of itself, it’s just glue between layers.
The layers are unidirectional so what’s in the center doesn’t know about what’s outside, this is mainly to avoid unintended coupling. If you want to learn more about this you can read this amazing article by @hgraca.
Now that you have a basic understanding of the architecture I’ll be using, I can start explaining what I did on day 5.
Saving the card was not as straightforward as I thought it would be, the problem being that you can create a card even before you create the deck 🤔. I’ve used apps before where you’re required to save the main document before you can attach things to it and I never liked that as I user.
So in this case we need a way to be able to save cards even before there’s a deck for them. To solve that I first started by always assigning an id
to a deck when the edit deck screen is opened.
fun loadDeck(deckId: String?) {
if (deckId != null) { // We're loading an existing deck
state.id = deckId
....
} else { // Assign random id to new deck
state.id = UUID.randomUUID().toString()
}
}
I know generating an ID on the client can be problematic because it might conflict with an existing it but I don’t think that’s likely to happen in this application given its size. It’s pretty simple and works for now, if needed I can come back to this later and improve this.
Okay, now at least we have a way to identify decks even before they are saved. If you remember in day 4 I made the edit deck route accept either a cardId
or a deckId
and this is where the deckId
comes from.
To save the card, I started by creating the SaveDeckCardUseCase
, it takes a SaveDeckCardData
and returns the saved DeckCard
.
interface SaveDeckCardUseCase {
suspend fun save(card: SaveDeckCardData): DeckCard
}
The SaveDeckCardData
is a DTO class, it’s just meant to contain the data that will be sent to the backend or wherever it may go. The cardId
will be null if we’re creating a new card and the deckId
is used to know whose deck this card belongs to.
data class SaveDeckCardData(
val cardId: String?,
val deckId: String,
val input: String,
val outputs: List<String>
)
Most people don’t create a new class like I did, you may ask: “Why am I not using the DeckCard class we already have”? The main reason I’m doing that is that they change for different reasons.
Right now they’re pretty similar but the DeckCard
class will later also have fields such as userId
, createdAt
, reviewedAt
and many more fields that are not editable. For that reason I create a class just to represent what can be editable like the input, output, images and so on. The cardId
and deckId
are not editable but I need them to be able to identify who these changes belong to.
This adds complexity and you may think it’s not worth the additional complexity here, I however, think that’s a good trade off.
I implemented the interface and because there’s no business logic validation here I’m just calling the repository to persist the changes.
class SaveCardUseCaseImpl @Inject constructor(
private val repository: DeckCardRepository,
) : SaveDeckCardUseCase {
override suspend fun save(card: SaveDeckCardData): DeckCard {
return repository.saveCard(card)
}
}
Now moving on the the repository, I’m not sure if I’ll publish this app to the Play Store. Writing a backend service is time consuming so I went with a simpler approach, an in memory repository that just mocks the backend service.
The way I’m implementing my repository is not the right way to use one but that’s okay for now, I just need something that pretends to be the backend> If I want, I can actually code a backend service in the future but most importantly I won’t have to change the rest of my app because of that. This is the power of separation of concerns and abstraction. The rest of the app doesn’t know if the repository is calling a backend service, storing things locally or in memory.
Day 6
By day 6 I was a little lost, I was not sure if the way I was building things with Compose was right. It looked like there were many code smells in the code I was writing.
To try to fix that, I spent some time analyzing other Jetpack Compose projects such as NowInAndroid, Reply, Crane and Jetcaster. I fetched them locally and went through their code to see how those developers were using Compose.
My main doubt was about the communication between composables and view models. After having analyzed these repositories I realized the way I using view models was not so different. Nonetheless it was worth spending time to see how other developers were coding things.
This was also the day I made the EditCardNavigationParams
, requireNotNull
and sealed interface
modifications I mentioned earlier.
Similarly to how I saved cards on day 5, I created the use case to save decks. The use case follows the same pattern but here you’ll be able to understand a little better why I use DTOs.
First of all, creating/editing cards is not part of the deck, these are 2 separate things. When editing the deck I can change the title and reorder cards for now. My DTO reflects exactly that.
data class SaveDeckData(
val deckId: String,
val title: String,
val cards: List<SaveDeckCardData>
)
data class SaveDeckCardData(
val cardId: String,
val position: Int
)
In the SaveDeckData
you can see the title and cards field, the SaveDeckCardData
contains the cardId
and position
. Using this data I can update the title and reorder the cards.
Day 7 [Code Diff]
I started day 7 by creating an use case to load a deck. I created the GetDeckUseCase
and hook ed it up in my EditDeckViewModel
.
fun loadDeck(deckId: String?) {
...
viewModelScope.launch {
runCatching {
state.isLoading = true
getDeckUseCase.get(deckId)
}
.onFinally { state.isLoading = false }
.onSuccess { deck ->
state.applyEntity(deck ?: run {
Logger.e("Tried to load a deck that doesn't exist | id: $deckId")
return@onSuccess
})
}
}
...
}
Basically the way I’m using runCatching
is setting isLoading
to true before I call a suspend function and on the onFinally
callback setting it to false. onFinally
is a custom extension I created and it runs regardless of failure or success.
/**
* Runs [action] regardless of the result
*/
@OptIn(ExperimentalContracts::class)
inline fun <T> Result<T>.onFinally(
action: () -> Unit
): Result<T> {
contract {
callsInPlace(action, InvocationKind.AT_MOST_ONCE)
}
action()
return this
}
If the request succeeds then I map the entity to the state. To map entities to state classes I’m creating extensions called {StateClass}.applyEntity
. The different between state
and model
classes are that state
classes are mutable while model
classes aren’t.
private fun EditDeckState.applyEntity(deck: Deck) {
title = deck.title
cards = deck.cards.toModel()
}
private fun List<DeckCard>.toModel() = map { card ->
EditDeckCardModel(card.id, card.input, card.outputs)
}
I still haven’t add error handling or loading indicators to my screens and these are pretty important for user feedback. Error handling is something I’m gonna add later so for now I just tried creating a full screen loading indicator but was not able to make it work like I wanted. I left the app without any indicators for now but I’ll get back to this when I touch the theming part.
Now decks are loaded when I open the edit deck screen but there’s no way to access those decks so after that I did the same thing for the DeckLibraryRoute
. I created a new use case, called it from the view model and mapped the entity to state.
The difference is that when the deck component is clicked I’m navigating to the edit deck route.
fun onDeckClicked(deckModel: LibraryDeckModel) {
viewModelScope.launch {
_action.emit(DeckLibraryAction.NavigateToEditDeck(deckModel.id))
}
}
I also specified an unique key for each item in my LazyColumn
. According to the documentation:
When you specify the key the scroll position will be maintained based on the key, which means if you add/remove items before the current visible item the item with the given key will be kept as the first visible one.
data class EditDeckCardModel(
val id: String,
...
)
LazyColumn(...) {
items(state.cards, key = { it.id }) { model ->
...
}
}
I got tired of creating new decks and cards every time I open the app so I create a fake list of decks that are used by the repository when it’s created. I basically created a list with many fake cards and then a few decks that pick 14 random cards from this list. At least for now I don’t need to waste time recreating the same decks every time I build the app.
private val fakeCards = listOf(
DeckCard(
id = "",
deckId = "",
input = "Car",
outputs = listOf("Auto"),
),
...
)
val fakeDecks = listOf(
Deck(
id = "deck1",
title = "10 Italian Words",
cards = fakeCards.shuffled().take(14)
.map { it.copy(id = UUID.randomUUID().toString(), deckId = "deck1") },
),
...
)
On my initial design, decks also have images like flags but building that takes time and doesn’t bring a lot of value. For now I’ll be removing the images from decks and just assigning a random color/gradient to them.
Day 8 [Code Diff]
I started day 8 by improving navigating between text fields. I modify the “Input” text field to have the “Next” IME action, this will cause the focus to move the to “output” field when “next” is clicked.
TextField(
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
)
I also found the first bug of this app. Instead of updating the deck with the new title I was just setting the existing title again. Fortunately this was easy so solve but the over time bugs will likely take longer to solve. This is a good point for me to explain why I’m not adding tests to my code even though I think testing is really important.
Nobody adds tests to their code just for the sake of doing it, they expect those tests to be useful, to help them catch bugs, serve as documentation and so on.
I’m the only person working on this app and this project will likely end in less than 6 months. I have about 1-1.5hrs every day to work on this project. My time is pretty limited and adding tests is not something I think will be worth my time given these circumstances.
If this was a project multiple people were working on or it was expected to live longer then I’d certainly add tests but that’s not the case here.
In the edit deck screen there’s a list of cards that belong to that deck, however, if there are many cards, they won’t fit in the screen so I had to add scroll support to that screen.
Initially I tried to support scroll by adding the verticalScroll
modifier to the Column
wrapping the LazyColumn
because I want to whole screen to be scrollable. That didn’t work, LazyColumn doesn’t support nested scroll.
What I did is pretty similar to having an adapter with multiple view holders. I defined one item for the whole part above the card list, one item for each card list and another item for everything below the card list. That seems to solve the problem and works very well.
LazyColumn(
...
) {
item {
... // title text field, instructions
}
items(state.cards, key = { it.id }) { model ->
... // cards
}
item {
... // new card button
}
}
I also add a click handler to the card component so it navigates to the edit card route and finished the day by creating a use case to load the card and display it in the edit card screen.
With these updates, we come to the end of part 2. The app is starting to take shape and by the next update, I hope to have a MVP.
If you have any comments or suggestions, please reach out to me on Twitter.
Stay tuned for the next updates.
Photo by Amanda Jones on Unsplash