KMPizza
Kotlin Multiplatform + Pizza = ❤️

Step 24: Add a shared Log and navigate back on upload success in the iOS app

To investigate what’s happening in iOS when we want to save a new recipe let’s add a logging function to the shared module.

In commonMain util folder add a new file Log.kt with the following expect function:

 
expect val log: (String) -> Unit

Add corresponding actual functions to androidMain and iosMain, like we did before with CoroutineViewModel.

androidMain:

 
actual val log: (String) -> Unit = {
   Log.d("RecipesLog", it)
}

iosMain:

 
actual val log: (String) -> Unit = { println(it) }

Also add a missing actual function to jvmMain:

 
actual val log: (String) -> Unit = { }

Now add some log output to the saveRecipe function:

 
fun saveRecipe() {
   coroutineScope.launch {
           recipe.value?.let {
               if (it.title.isNotEmpty() && it.ingredients.isNotEmpty() && it.instructions.isNotEmpty()){
                   log(it.toString())
                   val result = recipeRepository.postRecipe(it)
                   log(result.toString())
                   result?.let { _upload.value = true }
               }
           }
   }
}

Make sure you imported the shared log function.
Build and run the Android app.
Try saving a new recipe, you’ll see:

 
D/RecipesLog: RecipeUiModel(id=0, title=Pie, ingredients=[Ingredient(id=0, name=Flour, amount=1.0, metric=Kg)], instructions=[Instruction(id=0, order=1, description=Mix all ingredients)])
D/RecipesLog: 10

Now do the same for iOS, you’ll see the following log output:

 
RecipeUiModel(id=0, title=Pie, ingredients=[Ingredient(id=null, name=Flour, amount=0.0, metric=Kg)], instructions=[Instruction(id=null, order=1, description=Mix all together)])
null

See the difference?
Right! We forgot that we use Long for our backend and shouldn’t be setting them to null, so let’s change it to 0 in AddButtons:

 
    viewModel.onIngredientsChanged(ingredient: Ingredient(id: 0, name: name, amount: amount ?? 0.0, metric: metric))
    viewModel.onInstructionsChanged(instruction: Instruction(id: 0, order: Int32((instructions?.count ?? 0) + 1), description: description))

Now run the app again…and it kind of works: if you start the app again and refresh the recipes list, you’ll see the new recipe!

However, we want to see the new recipe in the list once we save it.
We will fix it up later.

First let’s handle the upload value from the shared RecipeDetailViewModel, because we want to close the recipe detail view only after the request succeeded.

Change the upload variable in RecipeDetailState to KotlinBoolean?:

 
@Published private(set) var upload: KotlinBoolean? = nil

Add the following observe function in init():

 
viewModel.observeUpload{ upload in
            self.upload = upload
        }

Now we need to adjust the app’s behavior when Save Recipe button in clicked. Remove presentationMode.wrappedValue.dismiss() from the Save Button in RecipeDetailView, as well as Environment variable @Environment(\.presentationMode) var presentationModel.

In RecipesView add

 
@State var isRecipeDetailsShown: Bool = false

We’ll bind the isRecipeDetailsShown with the RecipeDetailsView, so that isRecipeDetailsShown is manipulated within the RecipeDetailsScreen and receives the result of the upload variable from the shared view model.

Add to the RecipeDetailState the following variable:

 
@Binding var isPresented: Bool

Change init():

 
 init(recipeId: KotlinLong?, isPresented: Binding<Bool>) {
        self.recipeId = recipeId
        self._isPresented = isPresented
        
        viewModel = RecipeDetailsViewModel(id: recipeId)
        
        viewModel.observeRecipe { recipe in
            self.recipe = recipe
        }
    }

And move the observeUpload() to saveRecipe():

 
 func saveRecipe(){
        viewModel.saveRecipe()
        
        viewModel.observeUpload { upload in
            self.upload = upload
            if ((self.upload?.boolValue ?? false) == true){
                self.isPresented = false
            }
        }
    }

Also change RecipeDetailView by passing the isPresented binding further to the state:

 
  init(id: KotlinLong?, isPresented: Binding<Bool>) {
        self.recipeId = id
        state = RecipeDetailState(recipeId: id, isPresented: isPresented)
    }

The body in RecipeDetailView should trigger saveRecipe():

 
 if (recipeId == nil) {
                Button("Save recipe") {
                    state.saveRecipe()
                }
                .buttonStyle(FilledButtonStyle())
                .padding()
            }

Then in RecipesView we’ll pass this state variable to our RecipeDetailsView like this.

 
 NavigationLink(destination: RecipeDetailView (id: recipe.id.toKotlinLong(), isPresented: self.$isRecipeDetailsShown)) {
                    RecipeView(item: recipe)
                }

Finally change the FloatingActionButton, so that we can signal when it’s pressed and the RecipeDetailsView is shown or not.

struct FloatingActionButton: View {
    @Binding var isPresented: Bool
    
    var body: some View {
        NavigationLink(destination: RecipeDetailView (id: nil, isPresented: $isPresented), isActive: $isPresented) {
            Image(systemName: "plus.circle.fill")
                .resizable()
                .frame(width: 56, height: 56)
                .foregroundColor(Color.accentColor)
                .shadow(color: .gray, radius: 0.2, x: 1, y: 1)
        }
        .simultaneousGesture(
            TapGesture().onEnded {
                isPresented = true
            })
    }
}

Don’t forget to pass the value to FloatingActionButton in RecipesView body:

 
FloatingActionButton(isPresented: self.$isRecipeDetailsShown)

Now if you test the app your RecipeDetailView will close only after it receives upload == true from the shared view model.

However, the recipe list still doesn’t update when we go back.
Let’s solve this problem now.
Open RecipeDetailState and add another variable just like you did with isPresented:

 
    @Binding var uploadSuccess: Bool

In init add uploadSuccess: Binding<Bool> to the signature and initiate with

 
        self._uploadSuccess = uploadSuccess

In saveRecipe below self.isPresented = false add self.uploadSuccess = true

In RecipeDetailView change the init section:

 
init(id: KotlinLong?, isPresented: Binding<Bool>, uploadSuccess: Binding<Bool> ) {
        self.recipeId = id
        state = RecipeDetailState(recipeId: id, isPresented: isPresented, uploadSuccess: uploadSuccess)
    }

Finally, in RecipesView add

 
@State var uploadSuccess: Bool = false 

and pass it to RecipeDetailView where necessary:

 
NavigationLink(destination: RecipeDetailView (id: recipe.id as? KotlinLong, isPresented: self.$isRecipeDetailsShown, uploadSuccess: self.$uploadSuccess)) {
        RecipeView(item: recipe)
    }
 FloatingActionButton(isPresented: self.$isRecipeDetailsShown, uploadSuccess: self.$uploadSuccess)
 

Also adjust FloatingActionButton struct:

 
NavigationLink(destination: RecipeDetailView (id: nil, isPresented: $isPresented, uploadSuccess: self.$uploadSuccess), isActive: $isPresented)
 

Add now an onAppear callback to NavigationLink in RecipesView:

 
    .onAppear {
        if uploadSuccess {
            state.viewModel.getRecipes()
        }

This will make sure that the shared view model gets the latest recipe list from the api and saves it in Kotlin Flow, which in turn passes it to Swift UI.

In the next step we’ll learn how to show images from our backend in the KMM Android and iOS apps.