1. Mealz App
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.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
-
now we use retrofit to get the data from the rest api and furthermore to convert them into objects
// 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
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 |
1.11. Display Image with Coil and Create Screen-Class
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()
}
}
}
}