= Android / Kotlin Lecture Notes :author: Thomas W. Stütz :revnumber: 1.0.0 :revdate: {docdate} :revremark: NVS 4th and 5th grade HTL Leonding Vocational College :encoding: utf-8 :experimental: ifndef::imagesdir[:imagesdir: images] //:toc-placement!: // prevents the generation of the doc at this position, so it can be printed afterwards :source-highlighter: rouge :sourcedir: ../src/main/java :icons: font :sectnums: // Nummerierung der Überschriften / section numbering :toc: left :toclevels: 5 // this instructions MUST set after :toc: :linkattr: // to be sure to process ", window="_blank"" //Need this blank line after ifdef, don't know why... ifdef::backend-html5[] // https://fontawesome.com/v4.7.0/icons/ icon:file-text-o[link=https://raw.githubusercontent.com/htl-leonding-college/android-classroom-course/main/asciidocs/{docname}.adoc] ‏ ‏ ‎ icon:github-square[link=https://github.com/htl-leonding-college/android-classroom-course] ‏ ‏ ‎ icon:home[link=https://htl-leonding-college.github.io/android-classroom-course] endif::backend-html5[] // print the toc here (not at the default position) //toc::[] == Mealz App * excerpt from https://www.udemy.com/course/jetpack-compose-masterclass === Update Dependencies === MVVM image::mealz/mvc.png[] image::mealz/mvp-mvvm.png[] image::mealz/mvvm.png[] image::mealz/aac-viewmodel.png[] === Create ViewModel .build.gradle (Module) [source,groovy] ---- implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1' ---- .MealsCategoriesViewModel.kt [source,kotlin] ---- 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 { return repository.getMeals().categories } } ---- .MainActivity [source,kotlin] ---- class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MealzAppTheme { MealsCategoriesScreen() } } } } @Composable fun MealsCategoriesScreen() { val viewModel : MealsCategoriesViewModel = viewModel() // <.> Text(text = "Hello Tom!") } @Preview(showBackground = true) @Composable fun DefaultPreview() { MealzAppTheme { MealsCategoriesScreen() } } ---- <.> The viewModel will NOT reinstantiated by every compose cycle, it will live as long the screen (the composable, activity or fragment) will live. === The MealsDB * https://www.themealdb.com/api.php * https://www.themealdb.com/api/json/v1/1/categories.php === JSON image::mealz/json-response.png[] === GSON deserialization * JSON -> data classes * we use gson * an valid alternative is https://github.com/square/moshi[moshi^] .build.gradle (Module) [source,groovy] ---- // Retrofit implementation 'com.google.code.gson:gson:2.10' ---- .MealResponse.kt [source,kotlin] ---- package at.htl.model.response import com.google.gson.annotations.SerializedName data class MealsCategoriesResponse( val categories: List ) { } 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 [source,kotlin] ---- package at.htl.model import at.htl.model.response.MealsCategoriesResponse class MealsRepository { fun getMeals(): MealsCategoriesResponse = MealsCategoriesResponse(arrayListOf()) } ---- === Retrofit * now we use retrofit to get the data from the rest api and furthermore to convert them into objects * https://square.github.io/retrofit/ [source,groovy] ---- // 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 [source,xml] ---- ---- .MealsWebService.kt [source,kotlin] ---- 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 { return api.getMeals() } interface MealsApi { @GET("categories.php") fun getMeals(): Call } } ---- .MealsRepository.kt [source,kotlin] ---- 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 { override fun onResponse( call: Call, response: Response ) { if (response.isSuccessful) successCallback(response.body()) } override fun onFailure(call: Call, t: Throwable) { } }) } } ---- .MealsCategoriesViewModel.kt [source,kotlin] ---- 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 [source,kotlin] ---- //... @Composable fun MealsCategoriesScreen() { val viewModel: MealsCategoriesViewModel = viewModel() val rememberedMeals: MutableState> = remember { mutableStateOf((emptyList())) } viewModel.getMeals { response -> val mealsFromTheApi = response?.categories rememberedMeals.value = mealsFromTheApi.orEmpty() } LazyColumn { items(rememberedMeals.value) { meal -> Text(text = meal.name) } } } //... ---- .manifest.xml [source,xml] ---- ---- === Coroutines image::mealz/coroutines1.png[] image::mealz/coroutines2.png[] image::mealz/coroutines3.png[] image::mealz/coroutines4.png[] .MealsWebService.kt [source,kotlin] ---- class MealsWebService { private lateinit var api: MealsApi // ... suspend fun getMeals(): MealsCategoriesResponse { // <.> return api.getMeals() } interface MealsApi { @GET("categories.php") suspend fun getMeals(): MealsCategoriesResponse // <.> } } ---- <.> convert to suspend function <.> convert to suspend function .MealsRepository.kt [source,kotlin] ---- class MealsRepository( private val webService: MealsWebService = MealsWebService() ) { suspend fun getMeals(): MealsCategoriesResponse { // <.> return webService.getMeals() } } ---- <.> convert to suspend function .MealsCategoriesViewModel.kt [source,kotlin] ---- class MealsCategoriesViewModel( private val repository: MealsRepository = MealsRepository() ) : ViewModel() { suspend fun getMeals(): List { return repository.getMeals().categories } } ---- .MainActivity.kt [source,kotlin] ---- // ... @Composable fun MealsCategoriesScreen() { val viewModel: MealsCategoriesViewModel = viewModel() val rememberedMeals: MutableState> = remember { mutableStateOf((emptyList())) } val coroutineScope = rememberCoroutineScope() // <.> LaunchedEffect(key1 = "GET_MEALS") { // <.> coroutineScope.launch(Dispatchers.IO) { val meals = viewModel.getMeals() rememberedMeals.value = meals } } LazyColumn { items(rememberedMeals.value) { meal -> Text(text = meal.name) } } } // ... ---- <.> get the corutine scope <.> use LaunchedEffects, so the coroutine will be startet once and not at every composition === 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 [source,kotlin] ---- class MealsCategoriesViewModel( private val repository: MealsRepository = MealsRepository() ) : ViewModel() { private val mealsJob = Job() // <.> init { val scope = CoroutineScope(mealsJob + Dispatchers.IO) scope.launch() { // <.> val meals = getMeals() mealsState.value = meals } } val mealsState: MutableState> = mutableStateOf((emptyList())) override fun onCleared() { super.onCleared() mealsJob.cancel() // <.> } private suspend fun getMeals(): List { return repository.getMeals().categories } } ---- <.> we create our own scope, even thats not necessary, because we could use the ViewModel-scope <.> we launch the scope once, when the ViewModel is created <.> we override a method, so the coroutine will be cancelled, when the ViewModel is destroyed. .MainActivity.kt [source,kotlin] ---- @Composable fun MealsCategoriesScreen() { val viewModel: MealsCategoriesViewModel = viewModel() val coroutineScope = rememberCoroutineScope() val meals = viewModel.mealsState.value // <.> LazyColumn { items(meals) { meal -> Text(text = meal.name) } } } ---- <.> here we create the ViewModel === Use the ViewModel-Scope .MealsCategoriesViewModel.kt [source,kotlin] ---- 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) { // <.> 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> = mutableStateOf((emptyList())) // <.> private suspend fun getMeals(): List { return repository.getMeals().categories } } ---- <.> we only use `viewModelScope.launch(Dispatchers.IO) { ... }` <.> we do not to override `onCleared()` because it is already implemented with the ViewModel-scope image::mealz/coroutines5.png[] image::mealz/coroutines6-logcat.png[] === Display Image with Coil and Create Screen-Class * https://coil-kt.github.io/coil/compose/[^] image::mealz/project-structure.png[] .MealsCategoriesScreen.kt [source,kotlin] ---- 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 [source,kotlin] ---- 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() } } } } ---- [source,kotlin] ---- ----