KMPizza
Kotlin Multiplatform + Pizza = ❤️

Step 19: Add a shared Recipe Details View Model

Now let’s create a proper ViewModel for the RecipeDetails screen. Keep in mind that we’ll also reuse this ViewModel to add new recipes afterwards.

First in the shared module in the model folder add an RecipeUiModel, which we’ll use as an interface between shared and UI layer:

 
data class RecipeUiModel (
   val id: Long = 0,
   val title: String,
   val ingredients: List<Ingredient> = listOf(),
   val instructions: List<Instruction> = listOf()
)

Go to shared -> remote -> RecipesRemoteSource.kt and add getRecipe():

 
suspend fun getRecipe(id: Long) = recipesApi.getRecipe(id)

And use it in RecipeRepository:

 
suspend fun getRecipe(id: Long) : RecipeResponse = recipeRemoteSource.getRecipe(id)

Next, we will adjust our models so that we can use them for our UI as well.
In the best case scenario you want to create UiModels and use them to display data, but in this tutorial we’ll just make id nullable for our shared models (except RecipeResponse, because it cannot arrive without id).
Also rename Recipe to RecipeRequest for clarity:

 
@Serializable
data class RecipeRequest (
   val id: Long? = 0,
   val title: String,
   val ingredients: List<Ingredient>,
   val instructions: List<Instruction>
)

@Serializable
data class Ingredient(
   val id: Long? = 0,
   val name: String,
   val amount: Double,
   val metric: String

)
@Serializable
data class Instruction(
   val id: Long? = 0,
   val order: Int,
   var description: String,
)

Then create helper functions to transform your RecipeResponse to RecipeUiModel and back:

 
fun RecipeResponse.toRecipeUiModel() = RecipeUiModel(
   id = id,
   title = title,
   ingredients = ingredients,
   instructions = instructions
)

fun RecipeUiModel.toRecipeRequest() = RecipeRequest(
   id = id,
   title = title,
   ingredients = ingredients,
   instructions = instructions
)

Now in shared->viewmodel folder add RecipeDetailsViewModel.kt.
Here create an EditRecipeChangeListener to help the ViewModel observe the changes in the user interface:

 
interface EditRecipeChangeListener { [1]
   fun onTitleChanged(title: String)
   fun onIngredientsChanged(ingredient: Ingredient)
   fun onInstructionsChanged(instruction: Instruction)
}

And finally add the RecipeDetailsViewModel itself:

 
class RecipeDetailsViewModel(private val id: Long?) : CoroutineViewModel(), KoinComponent, EditRecipeChangeListener { [2]
   private val recipeRepository: RecipeRepository by inject()

   private val _recipe = MutableStateFlow(EditRecipeUiModel()) [3]
   val recipe: StateFlow<EditRecipeUiModel> = _recipe

   init {
      id?.let { getRecipe(it) } [4]
   }

   fun getRecipe(id: Long) {
   coroutineScope.launch {
       _recipe.value = recipeRepository.getRecipe(id).toRecipeUiModel()[5]
   }
}


   @Suppress("unused")
fun observeRecipe(onChange: (RecipeUiModel?) -> Unit) { [6]
   recipe.onEach {
       onChange(it)
   }.launchIn(coroutineScope)
}


  override fun onTitleChanged(title: String) { [7]
   _recipe.value = _recipe.value?.copy(title = title)

}

override fun onIngredientsChanged(ingredient: Ingredient) {
   val ingredients = _recipe.value?.ingredients
   _recipe.value = _recipe.value?.copy(ingredients = ingredients?.plus(ingredient) ?: listOf(ingredient))

}

override fun onInstructionsChanged(instruction: Instruction) {
   val instructions = _recipe.value?.instructions
   _recipe.value = _recipe.value?.copy(instructions = instructions?.plus(instruction) ?: listOf(instruction))

}

[1] We created an interface to react to UI changes
[2] RecipeDetailsViewModel implements this interface
[3] The ViewModel also holds the mutable state flow of the recipe, which we’ll display on the platform side
[4] If we have a recipeId, we’ll load it from our backend, otherwise start with an empty new recipe
[5] Transform RecipeResponse to RecipeUiModel
[6] This method just like in RecipesViewModel will be used to observe the recipe flow from iOS
[7] These overridden methods belong to EditRecipeChangeListener interface. Here you modify the data in the Recipe StateFlow, which passes the newest data to the UI

To edit or create a recipe add an appropriate function in shared -> RecipesApi:

 
suspend fun postRecipe(recipe: Recipe): Long? {
   try{
       return client.post {
           json()
           apiUrl(RECIPES_BASE_URL)
           setBody(body)
       }.body()
   } catch (e: Exception){
       return null
   }
}

Use it in RecipesRemoteSource:

 
suspend fun postRecipe(recipe: Recipe) = recipesApi.postRecipe(recipe)

Add this to RecipeRepository:

 
suspend fun postRecipe(recipe: Recipe): Long?  = recipeRemoteSource.postRecipe(recipe)

In RecipeDetailsViewModel:

 
fun saveRecipe() {
   coroutineScope.launch {
       recipe.value?.let {
           if (it.title.isNotEmpty() && it.ingredients.isNotEmpty() && it.instructions.isNotEmpty()){
               val result = recipeRepository.postRecipe(it.toRecipeRequest())[1]
               result?.let { _upload.value = true } [2]
           }
       }
   }
}

[1] Transform RecipeUiModel to RecipeRequest
[2] Use the result value to send a signal back to the UI

Here we also add the upload lever to know when the new recipe was successfully uploaded, so that we can go back to the recipe list.
Add the following to the RecipeDetailsViewModel:

 
private val _upload = MutableStateFlow<Boolean>(false)
val upload: StateFlow<Boolean> = _upload

@Suppress("unused")
fun observeUpload(onChange: (Boolean?) -> Unit) {
   upload.onEach {
       onChange(it)
   }.launchIn(coroutineScope)
}

fun resetUpload(){
   _upload.value = false
}

The Recipe Details View Model is ready!
Now we can add new recipes to the database!
In the next step we’ll connect this ViewModel to our Android Compose UI.