‏ ‏ ‎ ‏ ‏ ‎

1. Mealz App

1.1. Update Dependencies

1.2. MVVM

mvc
mvp mvvm
mvvm
aac viewmodel

1.3. Create ViewModel

build.gradle (Module)
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
MealsCategoriesViewModel.kt
package at.htl.mealzapp.ui.meals

import androidx.lifecycle.ViewModel
import at.htl.model.MealsRepository
import at.htl.model.response.MealResponse

class MealsCategoriesViewModel(
    private val repository: MealsRepository = MealsRepository()
) : ViewModel() {

    fun getMeals(): List<MealResponse> {
        return repository.getMeals().categories
    }

}
MainActivity
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MealzAppTheme {
                MealsCategoriesScreen()
            }
        }
    }
}

@Composable
fun MealsCategoriesScreen() {
    val viewModel : MealsCategoriesViewModel = viewModel() (1)
    Text(text = "Hello Tom!")
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MealzAppTheme {
        MealsCategoriesScreen()
    }
}
1 The viewModel will NOT reinstantiated by every compose cycle, it will live as long the screen (the composable, activity or fragment) will live.

1.5. JSON

json response

1.6. GSON deserialization

  • JSON → data classes

  • we use gson

  • an valid alternative is moshi

build.gradle (Module)
// Retrofit
implementation 'com.google.code.gson:gson:2.10'
MealResponse.kt
package at.htl.model.response

import com.google.gson.annotations.SerializedName

data class MealsCategoriesResponse(
    val categories: List<MealResponse>
) {
}

data class MealResponse(
    @SerializedName("idCategory") val id: String,
    @SerializedName("strCategory") val name: String,
    @SerializedName("strCategoryDescription") val description: String,
    @SerializedName("strCategoryThumb") val imageUrl: String
)
MealsRepository.kt
package at.htl.model

import at.htl.model.response.MealsCategoriesResponse

class MealsRepository {

    fun getMeals(): MealsCategoriesResponse = MealsCategoriesResponse(arrayListOf())

}

1.7. Retrofit

// Retrofit
implementation 'com.google.code.gson:gson:2.10'
implementation 'com.squareup.retrofit2.retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
manifests/AndroidManifests.xml
<uses-permission android:name="android.permission.INTERNET" />
MealsWebService.kt
package at.htl.model.api

import at.htl.model.response.MealsCategoriesResponse
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET

class MealsWebService {

    private lateinit var api: MealsApi

    init {
        val retrofit = Retrofit.Builder()
            .baseUrl("https://www.themealdb.com/api/json/v1/1/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()

        api = retrofit.create(MealsApi::class.java)
    }


    fun getMeals(): Call<MealsCategoriesResponse> {
        return api.getMeals()
    }

    interface MealsApi {
        @GET("categories.php")
        fun getMeals(): Call<MealsCategoriesResponse>
    }

}
MealsRepository.kt
package at.htl.model

import at.htl.model.api.MealsWebService
import at.htl.model.response.MealsCategoriesResponse
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response

class MealsRepository(
    private val webService: MealsWebService = MealsWebService()
) {
    fun getMeals(
        successCallback: (response: MealsCategoriesResponse?) -> Unit
    ) {
        return webService.getMeals().enqueue(object : Callback<MealsCategoriesResponse> {
            override fun onResponse(
                call: Call<MealsCategoriesResponse>,
                response: Response<MealsCategoriesResponse>
            ) {
                if (response.isSuccessful)
                    successCallback(response.body())
            }

            override fun onFailure(call: Call<MealsCategoriesResponse>, t: Throwable) {

            }
        })
    }
}
MealsCategoriesViewModel.kt
package at.htl.mealzapp.ui.meals

import androidx.lifecycle.ViewModel
import at.htl.model.MealsRepository
import at.htl.model.response.MealsCategoriesResponse

class MealsCategoriesViewModel(
    private val repository: MealsRepository = MealsRepository()
) : ViewModel() {

    fun getMeals(
        successCallback: (response: MealsCategoriesResponse?) -> Unit
    ) {
        repository.getMeals() { response ->
            successCallback(response)
        }
    }
}
MainActivity.kt
//...

@Composable
fun MealsCategoriesScreen() {
    val viewModel: MealsCategoriesViewModel = viewModel()
    val rememberedMeals: MutableState<List<MealResponse>> = remember {
        mutableStateOf((emptyList<MealResponse>()))
    }
    viewModel.getMeals { response ->
        val mealsFromTheApi = response?.categories
        rememberedMeals.value = mealsFromTheApi.orEmpty()
    }
    LazyColumn {
        items(rememberedMeals.value) { meal ->
            Text(text = meal.name)
        }

    }
}

//...
manifest.xml
<uses-permission android:name="android.permission.INTERNET" />

1.8. Coroutines

coroutines1
coroutines2
coroutines3
coroutines4
MealsWebService.kt
class MealsWebService {

    private lateinit var api: MealsApi

    // ...

    suspend fun getMeals(): MealsCategoriesResponse { (1)
        return api.getMeals()
    }

    interface MealsApi {
        @GET("categories.php")
        suspend fun getMeals(): MealsCategoriesResponse (2)
    }

}
1 convert to suspend function
2 convert to suspend function
MealsRepository.kt
class MealsRepository(
    private val webService: MealsWebService = MealsWebService()
) {

    suspend fun getMeals(): MealsCategoriesResponse { (1)
        return webService.getMeals()
    }

}
1 convert to suspend function
MealsCategoriesViewModel.kt
class MealsCategoriesViewModel(
    private val repository: MealsRepository = MealsRepository()
) : ViewModel() {

    suspend fun getMeals(): List<MealResponse> {
        return repository.getMeals().categories
    }
}
MainActivity.kt
// ...

@Composable
fun MealsCategoriesScreen() {
    val viewModel: MealsCategoriesViewModel = viewModel()
    val rememberedMeals: MutableState<List<MealResponse>> = remember {
        mutableStateOf((emptyList<MealResponse>()))
    }
    val coroutineScope = rememberCoroutineScope()  (1)

    LaunchedEffect(key1 = "GET_MEALS") {  (2)
        coroutineScope.launch(Dispatchers.IO) {
            val meals = viewModel.getMeals()
            rememberedMeals.value = meals
        }
    }

    LazyColumn {
        items(rememberedMeals.value) { meal ->
            Text(text = meal.name)
        }

    }
}

// ...
1 get the corutine scope
2 use LaunchedEffects, so the coroutine will be startet once and not at every composition

1.9. Hoisting state and Coroutines

  • We don’t want to trigger the request for the meals in the compose function. We will transfer it to the ViewModel.

MealsCategoriesViewModel.kt
class MealsCategoriesViewModel(
    private val repository: MealsRepository = MealsRepository()
) : ViewModel() {

    private val mealsJob = Job()   (1)
    init {
        val scope = CoroutineScope(mealsJob + Dispatchers.IO)
        scope.launch() {  (2)
            val meals = getMeals()
            mealsState.value = meals
        }
    }

    val mealsState: MutableState<List<MealResponse>> = mutableStateOf((emptyList<MealResponse>()))

    override fun onCleared() {
        super.onCleared()
        mealsJob.cancel()  (3)
    }

    private suspend fun getMeals(): List<MealResponse> {
        return repository.getMeals().categories
    }
}
1 we create our own scope, even thats not necessary, because we could use the ViewModel-scope
2 we launch the scope once, when the ViewModel is created
3 we override a method, so the coroutine will be cancelled, when the ViewModel is destroyed.
MainActivity.kt
@Composable
fun MealsCategoriesScreen() {
    val viewModel: MealsCategoriesViewModel = viewModel()
    val coroutineScope = rememberCoroutineScope()
    val meals = viewModel.mealsState.value   (1)

    LazyColumn {
        items(meals) { meal ->
            Text(text = meal.name)
        }
    }
}
1 here we create the ViewModel

1.10. Use the ViewModel-Scope

MealsCategoriesViewModel.kt
class MealsCategoriesViewModel(
    private val repository: MealsRepository = MealsRepository()
) : ViewModel() {

    val TAG = MealsCategoriesViewModel::class.java.name

    init {
        Log.d(TAG, "we are about to launch a coroutine")
        viewModelScope.launch(Dispatchers.IO) {  (1)
            Log.d(TAG, "we have launched the coroutine")
            val meals = getMeals()
            Log.d(TAG, "we have received the asynchronous data")
            mealsState.value = meals
        }
        Log.d(TAG, "other work")
    }

    val mealsState: MutableState<List<MealResponse>> = mutableStateOf((emptyList<MealResponse>()))

    (2)

    private suspend fun getMeals(): List<MealResponse> {
        return repository.getMeals().categories
    }
}
1 we only use viewModelScope.launch(Dispatchers.IO) { …​ }
2 we do not to override onCleared() because it is already implemented with the ViewModel-scope
coroutines5
coroutines6 logcat

1.11. Display Image with Coil and Create Screen-Class

project structure
MealsCategoriesScreen.kt
package at.htl.mealzapp.ui.meals

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import at.htl.mealzapp.ui.theme.MealzAppTheme
import at.htl.model.response.MealResponse
import coil.compose.AsyncImage

@Composable
fun MealsCategoriesScreen() {
    val viewModel: MealsCategoriesViewModel = viewModel()
    val meals = viewModel.mealsState.value

    LazyColumn(contentPadding = PaddingValues(16.dp)) {
        items(meals) { meal ->
            MealCategory(meal)
        }
    }
}

@Composable
fun MealCategory(meal: MealResponse) {
    Card(
        shape = RoundedCornerShape(8.dp),
        elevation = 2.dp,
        modifier = Modifier
            .fillMaxWidth()
            .padding(top = 16.dp)
    ) {
        Row {
            AsyncImage(
                model = meal.imageUrl,
                contentDescription = null,
                modifier = Modifier
                    .size(88.dp)
                    .padding(4.dp)
            )
            Column(
                modifier = Modifier
                    .align(Alignment.CenterVertically)
                    .padding(16.dp)
            ) {
                Text(text = meal.name)
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MealzAppTheme {
        MealsCategoriesScreen()
    }
}
MainActivity.kt
package at.htl.mealzapp.ui

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import at.htl.mealzapp.ui.meals.MealsCategoriesScreen
import at.htl.mealzapp.ui.theme.MealzAppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MealzAppTheme {
                MealsCategoriesScreen()
            }
        }
    }
}