학기말 시험이 끝나고 그간 공부한 자료를 스토리지 박스에 넣는 과정이 반복되고 있다. 이런 작은 일이 그사이 받았던 스트레스를 조금은 평탄하게 해주지 않을까 하고, 배우면 배울수록 부족함을 더 확인했던 시간, 거기에 따라오던 수많은 상념도 담아서 보내버린다, 보내버리겠다는 생각으로 작은 의식을 치른다. 박스가 엄청 무거운 건 아니지만 그렇다고 가볍게 들리진 않는다. 그래도 뭘 하긴 했구나.

아무리 좋은 점수를 받아도 1년을 긴 학기 둘, 짧은 학기 하나로 보내면 내가 여기에서 시간을 이렇게 보내고 있는 것이 맞나 생각이 자연스럽게 든다. 남 생각 안 하고 내 삶만 보자, 내 성장만 보자, 어제의 나보다 오늘의 나를 생각하자면서도 어느 사이에 주변을 보고서는 나 자신을 평가하기 바빠진다. 잠깐 기분 전환한다고 소셜 미디어에 가까워질 때마다 결국 이런 비교와 평가가 나를 갉아먹는다. 내게 도움이 되는 부분과 빼앗아가는 것을 늘 저울질하면서, 로그아웃, 로그인, 로그아웃. 이런 지난한 싸움이 매일 로그인 화면 앞에서 반복된다. 체류 시간을 높이기 위해 디자인된 수많은 도구와의 싸움은 지루하게 끝이 나질 않는다. 어찌 됐든 자신을 깎는 고민을 안 하려면 결국 지금 앞에 있는 것만 보고 집중하는 것 말고는 큰 대안이 없다. 그런데도 왜 문제의 답이 가까이 있으면서도 나는 왜 그 답에 쉽게 설득되지 않을까. 꼭 멀리에서 들리는 큰 목소리만 답처럼 들리는 것일까. 아는 답을 듣기 위해서 또 로그인, 로그아웃.

이번에도 얇은 플래너 하나로 학기를 보냈다. 마음에 드는 적당한 규격과 양식의 플래너를 찾지 못했다. 연초에 한국서 큰마음 먹고 사 온 플래너도 결국 흐지부지됐다. 작년에 간단하게 만들었던 서식을 또 출력해서 작은 플래너를 만들었다. 내가 과목마다 얼마나 시간을 써야 하는지 명확하지 않은 학기 초에는 플래너에 많이 의존하게 된다. 학기가 절반쯤 지나면 플래너는 체크리스트 역할만 정도지 무슨 요일엔 수학이랑 물리, 무슨 요일에는 무엇, 대략적인 감각이 생기는데 그렇게 루틴에 익숙해지는 순간이 몰입을 돕는 것 같다. 주제에 흥미가 더 붙고 더 알고 싶어졌다. 가끔 달라지는 일정을 플래너에 적고 시간을 조정하다 보면 모든 걸 다 끝낸 것도 아닌데 만족감이 든다. 충분히 할 수 있다는 생각에 부담도 적고 적당히 유연한 서식도 한몫한다. 플래너에 적으면 어떻게든 결과를 볼 수 있다는 자신감도 생긴다. 단순한 시스템을 갖추고 유지하는 일은 코드 밖에서도 적용된다. 끝난 일은 색칠하고, 못 한 일은 긋고 옮긴다. 그렇게 날마다 펼쳐두던 플래너를 접어서 박스에 같이 넣으면 보람찬 기분과 함께 약간은 헛헛한 기분도 든다.

일상과는 거리가 먼 2020년이고 매일 현실감 없는 뉴스에 절망감을 느낀다. 주변은 건강해서 다행이라고 말하는 것도 너무 이기적이다 싶을 정도로 매일 올라가는 숫자들에 마음이 아리다. 복잡한 세상에 비춰보면 일상은 믿기 어려울 정도로 납작하고 단편적으로 변했다. 불안한 마음에 장 보러 가는 일을 최소로 줄였고 외식하는 일은 전혀 생각하지도 않는다. 그렇게 단단히 마음먹고 살다가도 운전하다가 창밖으로 마스크를 안 쓸 자유를 주장하는 사람들이 피켓을 들고 흔드는 모습을 보고는 망연해진다. 각자의 자리에서 각자 전쟁을 치르는 이 땅의 모습은 도무지 익숙해질 것 같지 않다. 나는 이런 사회에서 무엇을, 어떻게 기여할 수 있을까.

그런 욕심 내지는 바람을 갖고 있어서 그런지 누군가에게 도움이 되는 도구와 서비스를 만드는 일에서 효능감이 높다. 그래서 회사 생활에 만족도가 높았었고 공부를 계속 미루던 이유에도 한몫했었다. (다달이 들어오는 월급의 유혹도.) 학교에 다니게 되면서 수업 잘 듣고 과제 잘해서 지적 성장을 도모하고 그 와중에 학점도 잘 챙기면 그것으로도 충분하다고 생각하지만 내가 효능감을 느끼는 영역과는 조금 거리가 멀다. 되려 괜찮은 점수를 받아도 조금 부족하거나 실수한 부분에서 스트레스를 더 받는 편이다. 내가 이런 부분에서 스트레스받는다는 점을 몰랐던 것은 아니지만 그동안은 거의 잊다시피 하고 지내고 있었다. 학교 다니면서 다시 코피가 나기 시작했다. 어릴 때도 늘 하루걸러 코피가 나고 그랬는데 인제야 이게 스트레스 지표나 마찬가지였구나, 깨달았다. 이해가 안되는 내용을 이해하려고 시간을 쓰고, 코피가 나서 그걸 막고 있다 보면 자괴감 비슷한 것이 밀려와서 심란해진다. 내가 하고 싶은 일을 하려면 이 길을 어떻게든 걸어야 해, 행동으로 옮기는 일은 정말 쉽지 않다.

어떻게 만든 기회인데 최선을 다해야 한다는 사실을 알지만 그렇다고 그게 쉽게 되는 일은 아니다. 어떤 이유를 만들어서든 매몰되지 않도록 눈을 가리고 가볍게 지나야 한다는 점은 알지만 어렵다. 3학기가 지나고 나니 나름 시스템이 생겼는지 대략 어떻게 준비하고 공부하면 되는지 감각이 생겼다. 그리고 큰 덩어리를 잘게 쪼개서 조금씩 해결하고 성취에 기뻐하고 작은 보상을 계속 준비하는 것만 어려움을 덜어내는 방법인 것 같다. 어쩌면 당연한 말이고 듣기도 많이 들었지만 겪고 체득하기 전까지는 내 것이 아니란 걸 또 배우게 되었다. 아는 것을 실천으로 옮길 수 있는 것도 능력이고 연습을 통해 근육을 쌓아야만 써먹을 수 있다는 것. 부지런히 근육을 만들어야겠다.

비주얼 타이머는 방학이 될 때마다 작더라도 업데이트를 하고 있다. 처음 만들고 나서는 에너지를 너무 많이 쏟아서 기대보다 낮은 성장에 실망했는데 시간이 지나 약간 거리를 두고 보니 그럴 필요가 전혀 없었다. 학업에 집중하다 보니 이 프로젝트도 조금 더 관망할 수 있어서 마음이 아주 홀가분해졌다. 여전히 엄청나게 많은 사용자가 있는 것은 아니지만 꾸준하게 사용하는 분도 꽤 있고 앱이 좋다며 장문의 리뷰와 피드백을 보내주는 분도 있다. 내게는 직장 생활 당시의 감각을 깨워주는 느낌도 있는 데다 내가 원하는 것처럼 누군가에게 도움이 되고 있다는 점에서 자신감을 줍는다. 후원으로 받은 소중한 돈으로 내년 애플 개발자 프로그램 비용도 지출했다. 처음 계획했던 범위에서는 앱을 다 만들었기 때문에 무얼 어떻게 개선할지 방향이 고민이다. 안드로이드로도 출시해보고 싶어서 코틀린 강의도 틈틈이 봤는데 다음 업데이트에 안드로이드도 포함되면 좋겠다.

연초에 목표로 했던 것을 적어보면,

  • 책 읽기: 거의 꽝 (< 5권), 대신에 읽기 과제가 많은 수업을 여럿 들었으니까...
  • 운동량 늘리기: 꽝. 애플워치가 생긴 이후로 조금 하긴 했지만.
  • 회고 주기적으로 하기: 꽝. 과제에 치여서 글을 거의 못 씀.
  • 시간 관리하기: 조금 성공. 조금은 더 철저할 필요가 있지 않았나.

내년 내 모습을 생각해보면 여전히 학교에서 씨름하고 있을 예정이다. 수업도 좀 더 어려워질 예정이고 시간도 많이 쓸 일이 생겨서 긴장되지만 지금 해온 것만큼 해낼 수 있었으면 좋겠다. 일상도 빨리 되찾을 수 있으면 좋겠다. 운동이나 글 쓰기, 책 읽기는 매년 목표지만 이번에도 또 다이어리 앞 장에 적어본다. 가족과 함께 집에 있는 시간은 판데믹 탓에 많아졌지만 온라인으로 전환된 수업이 대중없이 시간을 쓰게 만들어서 몸만 같이 있고 정신은 저 멀리 떠나있던 적도 많았다. 밖에서는 추억을 만들기 어렵더라도 집에서 무엇이든 재미있는 일을 더 꾸며봐야겠다. 제주에 있는 가족들도 많이 보고 싶지만 한국에는 언제 가게 될지 몰라서 좀 아쉽다.

올해도 수고가 많았다. Stop and smell the roses 🌹🌹🌹.

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals 부터.

Starter code

DevBytes starter.

/**
 * VideoHolder holds a list of Videos.
 *
 * This is to parse first level of our network result which looks like
 *
 * {
 *   "videos": []
 * }
 */
@JsonClass(generateAdapter = true)
data class NetworkVideoContainer(val videos: List<NetworkVideo>)

/**
 * Videos represent a devbyte that can be played.
 */
@JsonClass(generateAdapter = true)
data class NetworkVideo(
        val title: String,
        val description: String,
        val url: String,
        val updated: String,
        val thumbnail: String,
        val closedCaptions: String?)

/**
 * Convert Network results to database objects
 */
fun NetworkVideoContainer.asDomainModel(): List<DevByteVideo> {
    return videos.map {
        DevByteVideo(
                title = it.title,
                description = it.description,
                url = it.url,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

Add an offline cache using Room

Add a dependency.

// build.gradle (Module:app)
// Room dependency
def room_version = "2.1.0-alpha06"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"

Add a DB entity.

// DatabaseEntities.kt
@Entity
data class DatabaseVideo constructor(
        @PrimaryKey
        val url: String,
        val updated: String,
        val title: String,
        val description: String,
        val thumbnail: String
)

fun List<DatabaseVideo>.asDomainModel(): List<DevByteVideo> {
    return map {
        DevByteVideo(
                url = it.url,
                title = it.title,
                description = it.description,
                updated = it.updated,
                thumbnail = it.thumbnail
        )
    }
}

Update a data transfer object.

// DataTransferObjects.kt
/**
 * Convert Network results to database objects
 */
fun NetworkVideoContainer.asDomainModel(): List<DatabaseVideo> {
    return videos.map {
        DatabaseVideo(
                title = it.title,
                description = it.description,
                url = it.url,
                updated = it.updated,
                thumbnail = it.thumbnail)
    }
}

Add VideoDao.

// Room.kt
@Dao
interface VideoDao {
    @Query("select * from databasevideo")
    fun getVideos(): LiveData<List<DatabaseVideo>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertAll(videos: List<DatabaseVideo>)
}

@Database(entities = [DatabaseVideo::class], version = 1)
abstract class VideosDatabase : RoomDatabase() {
    abstract val videoDao: VideoDao
}

private lateinit var INSTANCE: VideosDatabase

fun getDatabase(context: Context): VideosDatabase {
    synchronized(VideosDatabase::class.java) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room.databaseBuilder(context.applicationContext,
                    VideosDatabase::class.java, "videos")
                    .build()
        }
    }
    return INSTANCE
}

Repository

A repository module handles data operations and allows you to use multiple backends.

// VideosRepository.kt
class VideosRepository(private val database: VideosDatabase) {
    val videos: LiveData<List<DevByteVideo>> = Transformations.map(database.videoDao.getVideos()) {
        it.asDomainModel()
    }

    suspend fun refreshVideos() {
        withContext(Dispatchers.IO) {
            Timber.d("Refresh videos is called");
            val playlist = DevByteNetwork.devbytes.getPlaylist()
            database.videoDao.insertAll(playlist.asDomainModel())
        }
    }
}

Update the view model to apply a new refresh strategy.

// DevByteViewModel.kt
class DevByteViewModel(application: Application) : AndroidViewModel(application) {
    // init the repository
    private val videosRepository = VideosRepository(getDatabase(application))

    // ...

    // Replace playlist
    val playlist = videosRepository.videos

    // ...

    init {
        refreshDataFromRepository()
    }

    /**
     * Refresh data from network and pass it via LiveData. Use a coroutine launch to get to
     * background thread.
     */
    private fun refreshDataFromRepository() {
        viewModelScope.launch {
            try {
                videosRepository.refreshVideos()
                _eventNetworkError.value = false
                _isNetworkErrorShown.value = false
            } catch (networkError: IOException) {
                // Show a Toast error message and hide the progress bar.
                if (playlist.value.isNullOrEmpty())
                    _eventNetworkError.value = true
            }
        }
    }
}

다음 챕터: Android Kotlin Fundamentals

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals and detail views with internet data 부터.

Add "for sale" images to the overview

// MarsProperty.kt
data class MarsProperty(
  val id: String,
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
) {
  val isRental
    get() = type == "rent"
}

Update grid_view_item.xml.

<layout ...>
  <FrameLayout
    android:layout_width="match_parent"
    android:layout_height="170dp">
    <ImageView
      android:id="@+id/mars_image"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:adjustViewBounds="true"
      android:padding="2dp"
      android:scaleType="centerCrop"
      app:imageUrl="@{property.imgSrcUrl}"
      tools:src="@tools:sample/backgrounds/scenic" />
    <ImageView
      android:id="@+id/mars_property_type"
      android:layout_width="wrap_content"
      android:layout_height="45dp"
      android:layout_gravity="bottom|end"
      android:adjustViewBounds="true"
      android:padding="5dp"
      android:scaleType="fitCenter"
      android:src="@drawable/ic_for_sale_outline"
      android:visibility="@{property.rental ? View.GONE : View.VISIBLE}"
      tools:src="@drawable/ic_for_sale_outline" />
  </FrameLayout>
  <data>
    <import type="android.view.View" />
    <variable
      name="property"
      type="com.example.android.marsrealestate.network.MarsProperty" />
  </data>
</layout>

Filter the results

The URL will be:

// https://android-kotlin-fun-mars-server.appspot.com/realestate?filter=buy

Update the Mars API service.

// MarsApiService.kt
enum class MarsApiFilter(val value: String) {
  SHOW_RENT("rent"),
  SHOW_BUY("buy"),
  SHOW_ALL("all")
}
// ...
interface MarsApiService {
  @GET("realestate")
  suspend fun getProperties(@Query("filter") type: String):
    List<MarsProperty>
}

Update the view model.

// OverviewViewModel.kt
class OverviewViewModel : ViewModel() {
  // ...
  init {
    getMarsRealEstateProperties(MarsApiFilter.SHOW_ALL)
  }

  fun updateFilter(filter: MarsApiFilter) {
    getMarsRealEstateProperties(filter)
  }

  private fun getMarsRealEstateProperties(filter: MarsApiFilter) {
    viewModelScope.launch {
      _status.value = MarsApiStatus.LOADING
      try {
        _properties.value = MarsApi.retrofitService.getProperties(filter.value)
        _status.value = MarsApiStatus.DONE

      } catch (e: Exception) {
        _status.value = MarsApiStatus.ERROR
        _properties.value = ArrayList()
      }
    }
  }
}
<!-- overflow_menu.xml -->
<menu ...>
  <item
    android:id="@+id/show_all_menu"
    android:title="@string/show_all" />
  <item
    android:id="@+id/show_rent_menu"
    android:title="@string/show_rent" />
  <item
    android:id="@+id/show_buy_menu"
    android:title="@string/show_buy" />
</menu>

Then, update the view model when user choose the menu.

// OverviewFragment.kt
class OverviewFragment : Fragment() {
  // ...
  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    viewModel.updateFilter(
      when (item.itemId) {
        R.id.show_rent_menu -> MarsApiFilter.SHOW_RENT
        R.id.show_buy_menu -> MarsApiFilter.SHOW_BUY
        else -> MarsApiFilter.SHOW_ALL
      }
    )
    return true
  }
}

Create a detail page with navigation

When the user taps the tile, pass the data to the detail page.

// DetailViewModel.kt
class DetailViewModel(marsProperty: MarsProperty,
    app: Application) : AndroidViewModel(app) {
  private val _selectedProperty = MutableLiveData<MarsProperty>()
  val selectedProperty: LiveData<MarsProperty>
    get() = _selectedProperty

  init {
    _selectedProperty.value = marsProperty
  }
}

Update the fragment_detail.xml.

<ImageView
  android:id="@+id/main_photo_image"
  ...
  app:imageUrl="@{viewModel.selectedProperty.imgSrcUrl}" />

<!-- ... -->
<data>
  <variable
    name="viewModel"
    type="com.example.android.marsrealestate.detail.DetailViewModel" />
</data>

Then, add navigation with passing property.

// OverviewViewModel.kt
class OverviewViewModel : ViewModel() {
  // ...
  private val _navigateToSelectedProperty = MutableLiveData<MarsProperty>()
  val navigateToSelectedProperty: LiveData<MarsProperty>
    get() = _navigateToSelectedProperty

  fun displayPropertyDetails(marsProperty: MarsProperty) {
    _navigateToSelectedProperty.value = marsProperty
  }

  fun displayPropertyDetailsComplete() {
    _navigateToSelectedProperty.value = null
  }
}

Add click listeners in the grid adapter and fragment.

// PhotoGridAdapter.kt
class PhotoGridAdapter(private val onClickListener: OnClickListener) : ListAdapter<MarsProperty, PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {
  // ...
  override fun onBindViewHolder(holder: MarsPropertyViewHolder, position: Int) {
    val marsProperty = getItem(position)
    holder.itemView.setOnClickListener {
      onClickListener.onClick(marsProperty)
    }
    holder.bind(marsProperty)
  }
  // ...
  class OnClickListener(val clickListener: (marsProperty: MarsProperty) -> Unit) {
    fun onClick(marsProperty: MarsProperty) = clickListener(marsProperty)
  }
}

Then, update the OverviewFragment.kt.

binding.photosGrid.adapter = PhotoGridAdapter(PhotoGridAdapter.OnClickListener {
    viewModel.displayPropertyDetails(it)
})

MarsProperty is unable to pass through the navigation yet. The class need to be Parcelable via @parcelize.

Open MarsProperty.kt and add @parcelize.

@Parcelize
data class MarsProperty(
  val id: String,
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
) : Parcelable {
  val isRental
    get() = type == "rent"
}

Add argument at detail fragment in nav_graph.xml.

<fragment
  android:id="@+id/detailFragment"
  ...>
  <argument
    android:name="selectedProperty"
    app:argType="com.example.android.marsrealestate.network.MarsProperty"
    />
</fragment>

Finally, add an observer to navigateToSelectedProperty.

//OverviewFragment.kt
// in `onCreateView()`
viewModel.navigateToSelectedProperty.observe(this, Observer {
  if (null != it) {
    this.findNavController()
      .navigate(OverviewFragmentDirections.actionShowDetail(it))
    viewModel.displayPropertyDetailsComplete()
  }
})

Add initial logic for the detail fragment.

// DetailFragment.kt
class DetailFragment : Fragment() {
  override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
      savedInstanceState: Bundle?): View? {
    val application = requireNotNull(activity).application
    val binding = FragmentDetailBinding.inflate(inflater)
    binding.lifecycleOwner = this

    val marsProperty = DetailFragmentArgs.fromBundle(arguments!!).selectedProperty
    val viewModelFactory = DetailViewModelFactory(marsProperty, application)
    binding.viewModel = ViewModelProvider(this, viewModelFactory).get(DetailViewModel::class.java)

    return binding.root
  }
}

To update the detail page, add these string resources in strings.xml.

<string name="type_rent">Rent</string>
<string name="type_sale">Sale</string>
<string name="display_type">For %s</string>
<string name="display_price_monthly_rental">$%,.0f/month</string>
<string name="display_price">$%,.0f</string>

Then, add Transformations in the view model.

// DetailViewModel.kt
// In the detail viewmodel class
val displayPropertyPrice = Transformations.map(selectedProperty) {
  app.applicationContext.getString(
    when (it.isRental) {
      true -> R.string.display_price_monthly_rental
      false -> R.string.display_price
    }, it.price)
}

val displayPropertyType = Transformations.map(selectedProperty) {
  app.applicationContext.getString(
    when (it.isRental) {
      true -> R.string.type_rent
      false -> R.string.type_sale
    })
}

Add two textviews on the detail layout.

<TextView
  android:id="@+id/property_type_text"
  ...
  android:text="@{viewModel.displayPropertyType}"
  tools:text="To Rent" />

<TextView
  android:id="@+id/price_value_text"
  ...
  android:text="@{viewModel.displayPropertyPrice}"
  tools:text="$100,000" />

다음 챕터: Android Kotlin Fundamentals

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 8.1 Getting data from the internet 부터.

dep versions

Just in case.

buildscript {
  ext {
    // Versions for all the dependencies we plan to use. It's particularly useful for kotlin and
    // navigation where the versions of the plugin needs to be the same as the version of the
    // library defined in the app Gradle file

    version_android_gradle_plugin = '4.0.1'
    version_core = "1.3.1"
    version_constraint_layout = "1.1.3"
    version_glide = "4.8.0"
    version_kotlin = "1.3.72"
    version_lifecycle = "2.2.0"
    version_moshi = "1.8.0"
    version_navigation = "1.0.0"
    version_retrofit = "2.9.0"
    version_recyclerview = "1.0.0"
  }
}

Retrofit: A type-safe HTTP client

// build.gradle (project)
buildscript {
  ext {
    //...
    version_retrofit = "2.9.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.retrofit2:retrofit:$version_retrofit"
  implementation "com.squareup.retrofit2:converter-scalars:$version_retrofit"
}

android {
  // ...

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = JavaVersion.VERSION_1_8.toString()
  }
}

Then, create MarsApiService.kt.

package com.example.android.marsrealestate.network

import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory
import retrofit2.http.GET

// private const val BASE_URL = "https://android-kotlin-fun-mars-server.appspot.com/"

private val retrofit = Retrofit.Builder()
  .addConverterFactory(ScalarsConverterFactory.create())
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
      Call<String>
}

object MarsApi {
  val retrofitService: MarsApiService by lazy {
    retrofit.create(MarsApiService::class.java)
  }
}

Call the web service in OverviewViewModel.kt.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<String> {
        override fun onFailure(call: Call<String>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<String>, response: Response<String>) {
          _response.value = response.body()
        }
      }
    )
  }
}

Add INTERNET permission in app/manifests/AndroidManifest.xml.

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

Moshi: JSON parser library

// build.gradle (project)
buildscript {
  ext {
    //...
    version_moshi = "1.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.squareup.moshi:moshi-kotlin:$version_moshi"
  // change retrofit2's scalars converter
  implementation "com.squareup.retrofit2:converter-moshi:$version_retrofit"
}

Then, sync.

Create the property data class.

// MarsProperty.kt
data class MarsProperty(
  val id: String,
  // val img_src: String,
  // to remap the name
  @Json(name = "img_src") val imgSrcUrl: String,
  val type: String,
  val price: Double
)

Update MarsApiService.kt with new converter.

/// MarsApiService.kt

private val moshi = Moshi.Builder()
  .add(KotlinJsonAdapterFactory())
  .build()

private val retrofit = Retrofit.Builder()
  .addConverterFactory(MoshiConverterFactory.create(moshi))
  .baseUrl(BASE_URL)
  .build()

interface MarsApiService {
  @GET("realestate")
  fun getProperties():
    Call<List<MarsProperty>>
}

Update the ViewModel.

class OverviewViewModel : ViewModel() {
  // ...
  private fun getMarsRealEstateProperties() {
    MarsApi.retrofitService.getProperties().enqueue(
      object : Callback<List<MarsProperty>> {
        override fun onFailure(call: Call<List<MarsProperty>>, t: Throwable) {
          _response.value = "Failure: " + t.message
        }

        override fun onResponse(call: Call<List<MarsProperty>>, response: Response<List<MarsProperty>>) {
          _response.value =
            "Success: ${response.body()?.size} Mars properties retrieved"
        }
      }
    )
  }
}

It is a good idea to use coroutines for asynchronous.

// MarsApiService.kt
// Change the function with suspend
interface MarsApiService {
  @GET("realestate")
  suspend fun getProperties():
    List<MarsProperty>
}

Then, replace the ViewModel like the code below using try {}, catch {}, and viewModelScope.

// OverviewViewModel.kt
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      _response.value = "Success: ${listResult.size} Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Glide: Media mgmt and loading framework

// build.gradle (project)
buildscript {
  ext {
    //...
    version_glide = "4.8.0"
  }
}
// build.gradle (module: app)
dependencies {
  // ...
  implementation "com.github.bumptech.glide:glide:$version_glide"
}

Update the view model.

// OverviewViewModel.kt
private val _property = MutableLiveData<MarsProperty>()

val property: LiveData<MarsProperty>
  get() = _property

private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      val listResult = MarsApi.retrofitService.getProperties()
      if (listResult.size > 0) {
          _property.value = listResult[0]
      }
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Add BindingAdapters.kt.

// BindingAdapters.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .into(imgView)
  }
}

Update gridview_item.xml.

<layout ...>
  <ImageView
      android:id="@+id/mars_image"
      ...
      app:imageUrl="@{viewModel.property.imgSrcUrl}" />

  <data>
    <variable
      name="viewModel"
      type="com.example.android.marsrealestate.overview.OverviewViewModel" />
  </data>
</layout>

Change the layout from FragmentOverviewBinding to GridViewItemBinding in the fragment to quick test.

// val binding = FragmentOverviewBinding.inflate(inflater)
val binding = GridViewItemBinding.inflate(inflater)

Add loading and broken image

Add files in res/drawable. Here, ic_broken_image.xml and loading_animation.xml.

Update BindingAdapters.kt and reflect this file in bindImage().

// BindingAdapter.kt
@BindingAdapter("imageUrl")
fun bindImage(imgView: ImageView, imgUrl: String?) {
  imgUrl?.let {
    val imgUri = imgUrl.toUri().buildUpon().scheme("https").build()
    Glide.with(imgView.context)
      .load(imgUri)
      .apply(RequestOptions()
        .placeholder(R.drawable.loading_animation)
        .error(R.drawable.ic_broken_image))
      .into(imgView)
  }
}

Update RecyclerView with loaded data

// OverviewViewModel.kt
private val _properties = MutableLiveData<List<MarsProperty>>()
val properties: LiveData<List<MarsProperty>>
  get() = _properties

// ...
private fun getMarsRealEstateProperties() {
  viewModelScope.launch {
    try {
      _properties.value = MarsApi.retrofitService.getProperties()
      _response.value = "Success: Mars properties retrieved"
    } catch (e: Exception) {
      _response.value = "Failure: ${e.message}"
    }
  }
}

Update gridview_item.xml for the each property.

<layout ...>
  <ImageView
    android:id="@+id/mars_image"
    ...
    app:imageUrl="@{property.imgSrcUrl}" />

  <data>
    <variable
      name="property"
      type="com.example.android.marsrealestate.network.MarsProperty" />
  </data>
</layout>

Change the layout back to FragmentOverviewBinding in the fragment.

val binding = FragmentOverviewBinding.inflate(inflater)
// val binding = GridViewItemBinding.inflate(inflater)

Replace <TextView> to <RecyclerView> in fragment_overview.xml.

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/photos_grid"
  android:layout_width="0dp"
  android:layout_height="0dp"
  android:padding="6dp"
  android:clipToPadding="false"
  app:layoutManager=
      "androidx.recyclerview.widget.GridLayoutManager"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintLeft_toLeftOf="parent"
  app:layout_constraintRight_toRightOf="parent"
  app:layout_constraintTop_toTopOf="parent"
  app:spanCount="2"
  tools:itemCount="16"
  tools:listitem="@layout/grid_view_item" />

Then, Add the photo grid adapter.

// PhotoGridAdapter.kt
class PhotoGridAdapter : ListAdapter<MarsProperty, PhotoGridAdapter.MarsPropertyViewHolder>(DiffCallback) {

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MarsPropertyViewHolder {
    return MarsPropertyViewHolder(GridViewItemBinding.inflate(LayoutInflater.from(parent.context)))
  }

  override fun onBindViewHolder(holder: MarsPropertyViewHolder, position: Int) {
    val marsProperty = getItem(position)
    holder.bind(marsProperty)
  }

  companion object DiffCallback : DiffUtil.ItemCallback<MarsProperty>() {
    override fun areItemsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem == newItem
    }

    override fun areContentsTheSame(oldItem: MarsProperty, newItem: MarsProperty): Boolean {
      return oldItem.id == newItem.id
    }
  }

  class MarsPropertyViewHolder(private var binding: GridViewItemBinding) : RecyclerView.ViewHolder(binding.root) {
    fun bind(marsProperty: MarsProperty) {
      binding.property = marsProperty
      binding.executePendingBindings()
    }
  }
}

Update the Binding adapter.

// BindingAdapters.kt
@BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, data: List<MarsProperty>?) {
  val adapter = recyclerView.adapter as PhotoGridAdapter
  adapter.submitList(data)
}

Update the fragment.

<!-- fragment_overview.xml -->
<RecyclerView ...
  app:listData="@{viewModel.properties}" />

Add the adapter into the fragment.

binding.photosGrid.adapter = PhotoGridAdapter()
// Note: photos_grid is a name of the recycler view

Add error handling in RecyclerView

Add status in the view model.

enum class MarsApiStatus { LOADING, ERROR, DONE }

class OverviewViewModel : ViewModel() {

  // ...

  private val _status = MutableLiveData<MarsApiStatus>()
  val status: LiveData<MarsApiStatus>
    get() = _status

  private fun getMarsRealEstateProperties() {
    viewModelScope.launch {
      _status.value = MarsApiStatus.LOADING
      try {
        _properties.value = MarsApi.retrofitService.getProperties()
        _status.value = MarsApiStatus.DONE

      } catch (e: Exception) {
        _status.value = MarsApiStatus.ERROR
        _properties.value = ArrayList()
      }
    }
  }
}

Update BindingAdapters.kt.

// BindingAdapters.kt

@BindingAdapter("marsApiStatus")
fun bindStatus(statusImageView: ImageView, status: MarsApiStatus?) {
  when (status) {
    MarsApiStatus.LOADING -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.loading_animation)
    }
    MarsApiStatus.ERROR -> {
      statusImageView.visibility = View.VISIBLE
      statusImageView.setImageResource(R.drawable.ic_connection_error)
    }
    MarsApiStatus.DONE -> {
      statusImageView.visibility = View.GONE
    }
  }
}

Then, add the image view for the status.

<!-- fragment_overview.xml -->
<ImageView
   android:id="@+id/status_image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toRightOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:marsApiStatus="@{viewModel.status}" />

다음 챕터: Android Kotlin Fundamentals and detail views with internet data

신발에 발라먹어도 맛있다는 마법 소스.

준비물

  • 굴소스 2 스푼
  • 케찹 한 스푼
  • 다진마늘 한 스푼
  • 후추 가루 훅훅훅 다섯 번
  • 따임이나 허브 훅훅훅 세 번
  • 올리고당 한 스푼
  • 버터 두 스푼
  • 매실청 한 스푼
  • 굵은 고춧가루 한 스푼

조리

다 넣고 한번 부글부글 끓으면 끝! 참 쉬죠잉?

제주 향토 음식 갈치국.

준비물

  • 손질한 갈치
  • 표고버섯
  • 육수 낼것
  • 단호박
  • 다진 마늘
  • 청양고추
  • 국간장
  • 맛술
  • 소금
  • 후추

조리

  1. 육수: 그냥 물로 해도 맛있지만 다시팩이나 멸치육수를 내면 훨씬 깊은 맛을 낼수 있겠죠. 무를 넣고 푹 끓여주세요.
  2. 무가 투명하게 익기 시작하면 손질한 갈치와 표고버섯 넣기.
  3. 국간장 맛술 다진마늘 넣고 끓이기.
  4. 갈치 살이 익으면 단호박, 대파 넣기. 칼칼하게 고추도 넣으면 좋아요.

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: DiffUtil and data binding with RecyclerView 부터.

Useful Shortcuts

  • Refactor inline: Right-click on the property name, choose Refactor > Inline, or Control+Command+N (Option+Command+N on a Mac)

DiffUtil

When RecyclerView is being updated by notifyDataSetChanged() via SleepNightAdapter, the entire list is invalid then redraw every items in the list.

DiffUtil calculates the difference between the old list and the new one (Eugene W. Myers's difference algorithm) for efficiency.

Add a DiffCallback class for the comparison.

// SleepNightAdapter.kt

class SleepNightDiffCallback : DiffUtil.ItemCallback<SleepNight>() {
  override fun areItemsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
    return oldItem.nightId == newItem.nightId
  }

  override fun areContentsTheSame(oldItem: SleepNight, newItem: SleepNight): Boolean {
    return oldItem == newItem
  }
}

Then, Update the adapter to ListAdapter so that the adapter utilizes the DiffCallback class.

class SleepNightAdapter : ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {
  // ...
}

Then, remove data, getItemCount() because ListAdapter provides these features. Update onBindViewHolder() with getItem(position).

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
  val item = getItem(position)
  holder.bind(item)
}

Update the adapter observer in the fragment with submitList().

// SleepTrackerFragment.kt
sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
  it?.let {
    adapter.submitList(it)
  }
})

Add data binding to the layout file.

Put the cursor on the ConstraintLayout tag and press Alt+Enter (Option+Enter on a Mac). The intention menu (the "quick fix" menu) opens. Select Convert to data binding layout.

<!-- list_item_sleep_night.xml -->
<data>
  <variable
    name="sleep"
    type="com.example.android.trackmysleepquality.database.SleepNight"/>
</data>

Then, rebuild with clean project. Now, the data binding in the list adapter need to update.

// SleepNightAdapter.kt

companion object {
  fun from(parent: ViewGroup): ViewHolder {
    val layoutInflater = LayoutInflater.from(parent.context)
    val binding = ListItemSleepNightBinding.inflate(layoutInflater, parent, false)

    // put the cursor on binding, Alt+Enter then select
    //  "Change parameter 'itemView' type of primary constructor
    //   of class 'ViewHolder' to 'ListItemSleepNightBinding'"
    return ViewHolder(binding)
  }
}

As a result,

class ViewHolder private constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {}

will be like this.

class ViewHolder private constructor(itemView: ListItemSleepNightBinding) : RecyclerView.ViewHolder(itemView) {}

Still, it needs to be updated to the currect name and format.

class ViewHolder private constructor(val binding: ListItemSleepNightBinding) : RecyclerView.ViewHolder(binding.root) {}

// Note: not `binding`, `val binding`

Then, update all findViewById() to the members in the binding.

val sleepLength: TextView = binding.sleepLength
val quality: TextView = binding.qualityString
val qualityImage: ImageView = binding.qualityImage

// **Refactor > Inline** would be better for here.

Binding Adapters

With a @BindingAdapter annotation, the view can handle different types of the data from the data binding. By using this, the adapter don't need to know the implementation of the data transformation.

Create BindingUtils.kt and add these extendion functions.

@BindingAdapter("sleepDurationFormatted")
fun TextView.setSleepDurationFormatted(item: SleepNight) {
  text = convertDurationToFormatted(item.startTimeMilli, item.endTimeMilli, context.resources)
}

@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight) {
    text = convertNumericQualityToString(item.sleepQuality, context.resources)
}

@BindingAdapter("sleepImage")
fun ImageView.setSleepImage(item: SleepNight) {
  setImageResource(when (item.sleepQuality) {
    0 -> R.drawable.ic_sleep_0
    1 -> R.drawable.ic_sleep_1
    2 -> R.drawable.ic_sleep_2
    3 -> R.drawable.ic_sleep_3
    4 -> R.drawable.ic_sleep_4
    5 -> R.drawable.ic_sleep_5
    else -> R.drawable.ic_sleep_active
  })
}

Then, update bind() in the adapter class.

// SleepNightAdapter.kt
fun bind(item: SleepNight) {
  binding.sleep = item
  binding.executePendingBindings()
}

Update the layout file.

<!-- list_item_sleep_night.xml -->
<ImageView
  android:id="@+id/quality_image"
  ...
  app:sleepImage="@{sleep}" />
<TextView
  android:id="@+id/sleep_length"
  ...
  app:sleepDurationFormatted="@{sleep}" />
<TextView
  android:id="@+id/quality_string"
  ...
  app:sleepQualityString="@{sleep}" />

LayoutManager

LayoutManager gives an ability to change the depending views in the RecyclerView. e.g. Changing LinearLayout to GridLayout.

One of the main strengths of RecyclerView is that it lets you use layout managers to control and modify your layout strategy. A LayoutManager manages how the items in the RecyclerView are arranged.

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/sleep_list"
  ...
  app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

Remove app:layoutManager from the layout file.

Add GridLayoutManager at OnCreateView() in SleepTrackerFragment.kt.

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?): View? {

  // ...
  val manager = GridLayoutManager(activity, 3)
  binding.sleepList.layoutManager = manager
  return binding.root
}

To match the style with a new grid layout, change list_item_sleep_night.xml.

<ImageView
  android:id="@+id/quality_image"
  android:layout_width="@dimen/icon_size"
  android:layout_height="60dp"
  android:layout_marginTop="8dp"
  android:layout_marginBottom="8dp"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toTopOf="parent"
  tools:srcCompat="@drawable/ic_sleep_5"
  app:sleepImage="@{sleep}"/>

<TextView
  android:id="@+id/quality_string"
  android:layout_width="0dp"
  android:layout_height="20dp"
  android:layout_marginEnd="16dp"
  android:textAlignment="center"
  app:layout_constraintBottom_toBottomOf="parent"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintHorizontal_bias="0.0"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/quality_image"
  app:sleepQualityString="@{sleep}"
  tools:text="Excellent!"/>
// Single vertical
val manager = GridLayoutManager(activity, 1)

// Horizontal per 5
val manager = GridLayoutManager(activity, 5, GridLayoutManager.HORIZONTAL, false)

Clickable RecyclerView

  1. Need to listen to and receive the click and dtermine which item has been clicked
  2. Need to respond to the click with an action

Handle clicks in the ViewModel, not ViewHolder because the viewmodel can access the data and determine the response.

Note: Also, the click event is able to be placed in the RecyclerView.

Add SleepNightListener in SleepNightAdapter.kt.

class SleepNightListener(val clickListener: (sleepId: Long) -> Unit) {
  fun onClick(night: SleepNight) = clickListener(night.nightId)
}

Add the listener in the xml layout file and use it in the click event.

<data>
<!-- ... -->
  <variable
    name="clickListener"
    type="com.example.android.trackmysleepquality.sleeptracker.SleepNightListener" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
  ...
  android:onClick="@{() -> clickListener.onClick(sleep)}">
  <!-- ... -->

Then, update the adapter class.

// Add clickListener member
class SleepNightAdapter(val clickListener: SleepNightListener) : ListAdapter<SleepNight, SleepNightAdapter.ViewHolder>(SleepNightDiffCallback()) {

  // Pass the click listener
  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.bind(getItem(position), clickListener)
  }

  // ...
  class ViewHolder private constructor(val binding: ListItemSleepNightBinding) : RecyclerView.ViewHolder(binding.root) {

    // Update the function signature
    // Note: Refactor tool will catch this change
    fun bind(item: SleepNight, clickListener: SleepNightListener) {
      binding.sleep = item
      // Bind the listener into the layout
      binding.clickListener = clickListener
      binding.executePendingBindings()
    }
    // ...
  }
  // ...
}

Update the adapter at onCreateView() in the fragment.

val adapter = SleepNightAdapter(SleepNightListener { nightId ->
  Toast.makeText(context, "${nightId}", Toast.LENGTH_LONG).show()
})

Clean and rebuild the project if the update is not reflected.

Update the view model to handle the click event.

// SleepTrackerViewModel.kt
private val _navigateToSleepDetail = MutableLiveData<Long>()
val navigateToSleepDetail
  get() = _navigateToSleepDetail

fun onSleepNightClicked(id: Long) {
  _navigateToSleepDetail.value = id
}

fun onSleepDetailNavigated() {
  _navigateToSleepDetail.value = null
}

Then, update the fragment to trigger onSleepNightClicked().

val adapter = SleepNightAdapter(SleepNightListener { nightId ->
  sleepTrackerViewModel.onSleepNightClicked(nightId)
})

// add observer to handle the navigation
sleepTrackerViewModel.navigateToSleepDetail.observe(this, Observer { night ->
  night?.let {
  this.findNavController().navigate(
    SleepTrackerFragmentDirections
      .actionSleepTrackerFragmentToSleepDetailFragment(night))
  sleepTrackerViewModel.onSleepDetailNavigated()
  }
})

LiveData can be null so that SleepNight instance also can be null. Update BindingUtils.kt with an optional chaining.

@BindingAdapter("sleepQualityString")
fun TextView.setSleepQualityString(item: SleepNight?) {
  item?.let {
    text = convertNumericQualityToString(item.sleepQuality, context.resources)
  }
}

Header in RecyclerView

Two ways to add header in the list.

  1. Modify the adapter to use a different ViewHolder for the header
  2. Add a header into the dataset

Add DataItem to handle either SleepNight or Header. Add the class in the adapter file.

sealed class DataItem {
  abstract val id: Long
  data class SleepNightItem(val sleepNight: SleepNight): DataItem() {
    override val id = sleepNight.nightId
  }

  object Header: DataItem() {
    override val id = Long.MIN_VALUE
  }
}

Create header.xml.

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/text"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:textAppearance="?android:attr/textAppearanceLarge"
    android:text="@string/header_text"
    android:padding="8dp" />

Add header_text resource.

<string name="header_text">Sleep Results</string>

Add TextViewHolder in the adapter class.

class TextViewHolder(view: View) : RecyclerView.ViewHolder(view) {
  companion object {
    fun from(parent: ViewGroup): TextViewHolder {
      val layoutInflater = LayoutInflater.from(parent.context)
      val view = layoutInflater.inflate(R.layout.header, parent, false)
      return TextViewHolder(view)
    }
  }
}

Then, update the adapter file.


private val ITEM_VIEW_TYPE_HEADER = 0
private val ITEM_VIEW_TYPE_ITEM = 1

// Update the code from SleepNight to DataItem
// Note: two types in ListAdapter are changed.
class SleepNightAdapter(val clickListener: SleepNightListener) : ListAdapter<DataItem, RecyclerView.ViewHolder>(SleepNightDiffCallback()) {
    
  // Add getItemViewType() to distinguish the type
  override fun getItemViewType(position: Int): Int {
    return when (getItem(position)) {
      is DataItem.Header -> ITEM_VIEW_TYPE_HEADER
      is DataItem.SleepNightItem -> ITEM_VIEW_TYPE_ITEM
    }
  }
  // ...
}

Update onCreateViewHolder() too. The logic will create different view holder based on the view type.

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
  return when (viewType) {
    ITEM_VIEW_TYPE_HEADER -> TextViewHolder.from(parent)
    ITEM_VIEW_TYPE_ITEM -> ViewHolder.from(parent)
    else -> throw ClassCastException("Unknown viewType ${viewType}")
  }
}

Update onBindViewHolder().

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
  when (holder) {
    is ViewHolder -> {
      val nightItem = getItem(position) as DataItem.SleepNightItem
      holder.bind(nightItem.sleepNight, clickListener)
    }
  }
}

Update SleepNightDiffCallback.

class SleepNightDiffCallback : DiffUtil.ItemCallback<DataItem>() {

  override fun areItemsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
    return oldItem.id == newItem.id
  }

  @SuppressLint("DiffUtilEquals")
  override fun areContentsTheSame(oldItem: DataItem, newItem: DataItem): Boolean {
    return oldItem == newItem
  }
}

Add adding header function in the adapter class.

fun addHeaderAndSubmitList(list: List<SleepNight>?) {
  val items = when (list) {
    null -> listOf(DataItem.Header)
    else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
  }
  submitList(items)
}

Finally, replace submitList() in the fragment to addHeaderAndSubmitList().

sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
  it?.let {
    adapter.addHeaderAndSubmitList(it)
  }
})

Use coroutines for list manipulations

Updating data should not be in the UI thread. Use coroutines for that.

private val adapterScope = CoroutineScope(Dispatchers.Default)

fun addHeaderAndSubmitList(list: List<SleepNight>?) {
  adapterScope.launch {
    val items = when (list) {
      null -> listOf(DataItem.Header)
      else -> listOf(DataItem.Header) + list.map { DataItem.SleepNightItem(it) }
    }
    withContext(Dispatchers.Main) {
      submitList(items)
    }
  }
}

Extend the header

Extend the header width by the position.

// SleepTrackerFragment.kt
val manager = GridLayoutManager(activity, 3)

manager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
  override fun getSpanSize(position: Int) = when (position) {
    0 -> 3
    else -> 1
  }
}

다음 챕터: Android Kotlin Fundamentals: 8.1 Getting data from the internet

모카포트로 커피를 추출했을 때 쓴 맛이 나기 쉽다. 아래 방법으로는 에스프레소 보다는 연하고 드립 커피보다는 진한 느낌으로 추출됨. 깔끔한 커피 추출하는 방법:

  1. 포트를 깨끗하게 씻는다: 남아 있는 커피 찌꺼기가 커피를 더 맛있게 한다는 말은 미신이라고
  2. 포트를 보관할 때 꽉 잠그지 않는다: 고무 밴드(개스킷) 빨리 닳는다고
  3. 커피를 약간 거칠게 간다: 에스프레소 보다 조금 굵게 간다. 파우더와 모래알 사이 느낌으로
  4. 커피를 루스하게 꽉 채운다 (탬핑하지 않는다)
  5. 뜨거운 물을 넣는다
  6. 조립해서 바로 중불에 올린다: 커피콩이 추출 전에 뜨거워지면 쓴 맛이 더 난다고
  7. 커피가 다 추출되면 (치치 소리가 날 때) 꺼내서 하단부에 찬물을 끼얹어 추출이 더 진행되지 않게 한다
  8. 기호에 맞게 마신다

(Make Great Coffee with a Moka Pot)

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: Use LiveData to control button states 부터.

Use LiveData to control button states

// ViewModel
private val _navigateToSleepQuality = MutableLiveData<SleepNight>()
val navigateToSleepQuality: LiveData<SleepNight>
  get() = _navigateToSleepQuality

fun doneNavigating() {
  _navigateToSleepQuality.value = null
}

fun onStopTracking() {
  viewModelScope.launch {
    // ...
    _navigateToSleepQuality.value = oldNight
  }
}
// Fragment
sleepTrackerViewModel.navigateToSleepQuality.observe(this, Observer { night ->
  night?.let {
    // Passing the current nightId to the sleep quality fragment.
    this.findNavController()
      .navigate(SleepTrackerFragmentDirections.actionSleepTrackerFragmentToSleepQualityFragment(night.nightId))
    sleepTrackerViewModel.doneNavigating()
  }
})

Record the sleep quality

// SleepQualityViewModel.kt
class SleepQualityViewModel(
  private val sleepNightKey: Long = 0L,
  val database: SleepDatabaseDao) : ViewModel() {

  // to navigate back to the Tracker Fragment
  private val _navigateToSleepTracker = MutableLiveData<Boolean?>()

  val navigateToSleepTracker: LiveData<Boolean?>
    get() = _navigateToSleepTracker

  fun doneNavigating() {
    _navigateToSleepTracker.value = null
  }
}
fun onSetSleepQuality(quality: Int) {
  viewModelScope.launch {
    val tonight = database.get(sleepNightKey) ?: return@launch
    tonight.sleepQuality = quality
    database.update(tonight)

    // Setting this state variable to true will alert the observer and trigger navigation.
    _navigateToSleepTracker.value = true
  }
}

Then, add ViewModelFactory to compose instance.

class SleepQualityViewModelFactory(
      private val sleepNightKey: Long,
      private val dataSource: SleepDatabaseDao) : ViewModelProvider.Factory {
  @Suppress("unchecked_cast")
  override fun <T : ViewModel?> create(modelClass: Class<T>): T {
    if (modelClass.isAssignableFrom(SleepQualityViewModel::class.java)) {
      return SleepQualityViewModel(sleepNightKey, dataSource) as T
    }
    throw IllegalArgumentException("Unknown ViewModel class")
  }
}

Then, update the fragment that need to accept the arguments.

<data>
  <variable
    name="sleepQualityViewModel"
    type="com.example.android.trackmysleepquality.sleepquality.SleepQualityViewModel" />
  </data>
// onCreateView() in Fragment

val arguments = SleepQualityFragmentArgs.fromBundle(requireArguments())
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
val viewModelFactory = SleepQualityViewModelFactory(arguments.sleepNightKey, dataSource)
val sleepQualityViewModel = ViewModelProvider(this, viewModelFactory).get(SleepQualityViewModel::class.java)

binding.sleepQualityViewModel = sleepQualityViewModel

// Watch the state and navigate
sleepQualityViewModel.navigateToSleepTracker.observe(this, Observer {
  if (it == true) { // Observed state is true.
    this.findNavController().navigate(
      SleepQualityFragmentDirections.actionSleepQualityFragmentToSleepTrackerFragment())
    sleepQualityViewModel.doneNavigating()
  }
})

Then, add click handler on the xml.

android:onClick="@{() -> sleepQualityViewModel.onSetSleepQuality(5)}"

If the update doesn't reflect on the app, clear the cache (File > Invalidate Caches / Restart).

Control visibility

android:enabled="@{sleepTrackerViewModel.startButtonVisible}"
android:enabled="@{sleepTrackerViewModel.stopButtonVisible}"
android:enabled="@{sleepTrackerViewModel.clearButtonVisible}"
// ViewModel
val startButtonVisible = Transformations.map(tonight) {
   it == null
}
val stopButtonVisible = Transformations.map(tonight) {
   it != null
}
val clearButtonVisible = Transformations.map(nights) {
   it?.isNotEmpty()
}

Use Sanckbar

Sanckbar is a widget that provides brief feedback about an operation.

// ViewModel
private var _showSnackbarEvent = MutableLiveData<Boolean>()

val showSnackBarEvent: LiveData<Boolean>
  get() = _showSnackbarEvent

fun doneShowingSnackbar() {
  _showSnackbarEvent.value = false
}

// in onClear
fun onClear() {
  viewModelScope.launch {
    clear()
    tonight.value = null
    _showSnackbarEvent.value = true // add like this
  }
}

// Fragment
sleepTrackerViewModel.showSnackBarEvent.observe(this, Observer {
  if (it == true) { // Observed state is true.
    Snackbar.make(
      requireActivity().findViewById(R.id.content),
      getString(R.string.cleared_message),
      Snackbar.LENGTH_SHORT // How long to display the message.
    ).show()
    sleepTrackerViewModel.doneShowingSnackbar()
  }
})

RecyclerView

RecyclerView handles the current visible items in the list efficienctly through adapter pattern.

[[[[item] -> [ Adapter ] -> [ Screen ]
                  ↓
           [ ViewHolder ]
  • Data
  • A RecyclerView instance as container
  • A layout for one item of data
  • A layout manager
  • A view holder that extended from ViewHolder. It holds one item.
  • An adapter connects data to the RecyclerView and adapts the data to ViewHolder.

Implement

Add RecyclerView under ConstraintView in Fragment.

<androidx.recyclerview.widget.RecyclerView
  android:id="@+id/sleep_list"
  android:layout_width="0dp"
  android:layout_height="0dp"
  app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
  app:layout_constraintBottom_toTopOf="@+id/clear_button"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/stop_button" />

Add a dependency in app-level build.gradle.

implementation 'androidx.recyclerview:recyclerview:1.0.0'

Note: Every RecyclerView needs a layout manager that tells it how to position items in the list.

Then, create view holder for each item. Create text_item_view.xml layout.

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:paddingStart="16dp"
  android:paddingEnd="16dp"
  android:textSize="24sp" />

Open Util.kt and add TextItemViewHolder for now.

class TextItemViewHolder(val textView: TextView): RecyclerView.ViewHolder(textView)

Add SleepNightAdapter in the sleeptracker package.


class SleepNightAdapter : RecyclerView.Adapter<TextItemViewHolder>() {
  var data = listOf<SleepNight>()
    set(value) {
      field = value
      notifyDataSetChanged()
    }

  override fun getItemCount() = data.size

  // data injection in each view holder
  override fun onBindViewHolder(holder: TextItemViewHolder, position: Int) {
    val item = data[position]
      holder.textView.text = item.sleepQuality.toString()

      // Each conditions explicit to states because the `RecyclerView` reuse the view holder.
      if (item.sleepQuality <= 1) {
        holder.textView.setTextColor(Color.RED) // red
      } else {
        // reset
        holder.textView.setTextColor(Color.BLACK) // black
      }

  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TextItemViewHolder {
    val layoutInflater = LayoutInflater.from(parent.context)
    // parent view group is `RecyclerView`
    val view = layoutInflater.inflate(
        R.layout.text_item_view,
        parent,
        false) as TextView
    return TextItemViewHolder(view)
  }
}

Add the adapter in the fragment.

// onCreateView() in the fragment
val adapter = SleepNightAdapter()
binding.sleepList.adapter = adapter

Then, chanage nights to public in the ViewModel.

val nights = database.getAllNights()

Then, add observer in the Fragment.

sleepTrackerViewModel.nights.observe(viewLifecycleOwner, Observer {
  it?.let {
    adapter.data = it
  }
})

Extend ViewHolder

ViewHolder provides itemView and RecyclerView use this property to populate the data.

Create the item layout named list_item_sleep_night.xml.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="wrap_content">

  <ImageView
    android:id="@+id/quality_image"
    android:layout_width="@dimen/icon_size"
    android:layout_height="60dp"
    android:layout_marginStart="16dp"
    android:layout_marginTop="8dp"
    android:layout_marginBottom="8dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    tools:srcCompat="@drawable/ic_sleep_5" />

  <TextView
    android:id="@+id/sleep_length"
    android:layout_width="0dp"
    android:layout_height="20dp"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="16dp"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toEndOf="@+id/quality_image"
    app:layout_constraintTop_toTopOf="@+id/quality_image"
    tools:text="Wednesday" />

  <TextView
    android:id="@+id/quality_string"
    android:layout_width="0dp"
    android:layout_height="20dp"
    android:layout_marginTop="8dp"
    app:layout_constraintEnd_toEndOf="@+id/sleep_length"
    app:layout_constraintHorizontal_bias="0.0"
    app:layout_constraintStart_toStartOf="@+id/sleep_length"
    app:layout_constraintTop_toBottomOf="@+id/sleep_length"
    tools:text="Excellent!!!" />
</androidx.constraintlayout.widget.ConstraintLayout>

Add util functions.

private val ONE_MINUTE_MILLIS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.MINUTES)
private val ONE_HOUR_MILLIS = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)

fun convertDurationToFormatted(startTimeMilli: Long, endTimeMilli: Long, res: Resources): String {
  val durationMilli = endTimeMilli - startTimeMilli
  val weekdayString = SimpleDateFormat("EEEE", Locale.getDefault()).format(startTimeMilli)
  return when {
    durationMilli < ONE_MINUTE_MILLIS -> {
      val seconds = TimeUnit.SECONDS.convert(durationMilli, TimeUnit.MILLISECONDS)
      res.getString(R.string.seconds_length, seconds, weekdayString)
    }
    durationMilli < ONE_HOUR_MILLIS -> {
      val minutes = TimeUnit.MINUTES.convert(durationMilli, TimeUnit.MILLISECONDS)
      res.getString(R.string.minutes_length, minutes, weekdayString)
    }
    else -> {
      val hours = TimeUnit.HOURS.convert(durationMilli, TimeUnit.MILLISECONDS)
      res.getString(R.string.hours_length, hours, weekdayString)
    }
  }
}

fun convertNumericQualityToString(quality: Int, resources: Resources): String {
  var qualityString = resources.getString(R.string.three_ok)
  when (quality) {
    -1 -> qualityString = "--"
    0 -> qualityString = resources.getString(R.string.zero_very_bad)
    1 -> qualityString = resources.getString(R.string.one_poor)
    2 -> qualityString = resources.getString(R.string.two_soso)
    4 -> qualityString = resources.getString(R.string.four_pretty_good)
    5 -> qualityString = resources.getString(R.string.five_excellent)
  }
  return qualityString
}

Replace adapter class with the new nested view holder.

class SleepNightAdapter : RecyclerView.Adapter<SleepNightAdapter.ViewHolder>() {
  var data = listOf<SleepNight>()
    set(value) {
      field = value
      notifyDataSetChanged()
    }

  override fun getItemCount() = data.size

  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = data[position]
    val res = holder.itemView.context.resources
    holder.sleepLength.text = convertDurationToFormatted(item.startTimeMilli, item.endTimeMilli, res)
    holder.quality.text= convertNumericQualityToString(item.sleepQuality, res)
    holder.qualityImage.setImageResource(when (item.sleepQuality) {
      0 -> R.drawable.ic_sleep_0
      1 -> R.drawable.ic_sleep_1
      2 -> R.drawable.ic_sleep_2
      3 -> R.drawable.ic_sleep_3
      4 -> R.drawable.ic_sleep_4
      5 -> R.drawable.ic_sleep_5
      else -> R.drawable.ic_sleep_active
    })
  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val layoutInflater = LayoutInflater.from(parent.context)
    val view = layoutInflater.inflate(
          R.layout.list_item_sleep_night,
          parent,
          false)
    return ViewHolder(view)
  }

  class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val sleepLength: TextView = itemView.findViewById(R.id.sleep_length)
    val quality: TextView = itemView.findViewById(R.id.quality_string)
    val qualityImage: ImageView = itemView.findViewById(R.id.quality_image)
  }
}

Note: If the error occurs with the id, check the correct layout inflated.

Clean up

Use Refactor > Extract > Function by selecting function body except the assignment.

Put the cursor on the param and Alt+Enter (Option+Enter on Mac). Then, Convert parameter to receiver or Move to companion object.

Then, move the function into the class, and change appropriate access modifier.

Result:

class SleepNightAdapter : RecyclerView.Adapter<SleepNightAdapter.ViewHolder>() {
  var data = listOf<SleepNight>()
    set(value) {
      field = value
      notifyDataSetChanged()
    }

  override fun getItemCount() = data.size

  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val item = data[position]
    holder.bind(item)
  }

  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    return ViewHolder.from(parent)
  }

  class ViewHolder private constructor(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val sleepLength: TextView = itemView.findViewById(R.id.sleep_length)
    val quality: TextView = itemView.findViewById(R.id.quality_string)
    val qualityImage: ImageView = itemView.findViewById(R.id.quality_image)

    fun bind(item: SleepNight) {
      val res = itemView.context.resources
      sleepLength.text = convertDurationToFormatted(item.startTimeMilli, item.endTimeMilli, res)
      quality.text = convertNumericQualityToString(item.sleepQuality, res)
      qualityImage.setImageResource(when (item.sleepQuality) {
        0 -> R.drawable.ic_sleep_0
        1 -> R.drawable.ic_sleep_1
        2 -> R.drawable.ic_sleep_2
        3 -> R.drawable.ic_sleep_3
        4 -> R.drawable.ic_sleep_4
        5 -> R.drawable.ic_sleep_5
        else -> R.drawable.ic_sleep_active
      })
    }

    companion object {
      fun from(parent: ViewGroup): ViewHolder {
        val layoutInflater = LayoutInflater.from(parent.context)
        val view = layoutInflater.inflate(
          R.layout.list_item_sleep_night,
          parent,
          false)
        return ViewHolder(view)
      }
    }
  }
}

다음 챕터: Android Kotlin Fundamentals: DiffUtil and data binding with RecyclerView

Android Kotlin Fundamentals Course 코드랩 하면서 노트. Android Kotlin Fundamentals: 06.1 Create a Room database 부터.

Room database

A database library that is part of Android Jetpack. (SQLite)

# Recommended architecture

[ [observer] UI Controller (activity/fragment) ]
       ↑        ↓
[ [LiveData] ViewModel ]
                ↓
      [ Repository ]
         ↓      ↓
[ Database ]  [ Network ]

Codelab dependencies:

// app level build.gradle
// Support libraries
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.fragment:fragment:1.2.5"
implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"

// Android KTX
implementation 'androidx.core:core-ktx:1.3.1'

// Room and Lifecycle dependencies
implementation "androidx.room:room-runtime:$room_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
kapt "androidx.room:room-compiler:$room_version"
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"

// Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"

// Coroutines
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutine_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutine_version"

// Navigation
implementation "android.arch.navigation:navigation-fragment-ktx:$navigationVersion"
implementation "android.arch.navigation:navigation-ui-ktx:$navigationVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'

Create the entity

  • Entity: represents an object or concept, and its properties, to store in the database.
  • Query: a request for data or information from a database.

Define each entity as an annotated data class (Data access object, DAO).

Open SleepNight.kt in the .database package.

@Entity(tableName = "daily_sleep_quality_table")
data class SleepNight(
  @PrimaryKey(autoGenerate = true)
  var nightId: Long = 0L,

  @ColumnInfo(name = "start_time_milli")
  val startTimeMilli: Long = System.currentTimeMillis(),

  @ColumnInfo(name = "end_time_milli")
  var endTimeMilli: Long = startTimeMilli,

  @ColumnInfo(name = "quality_rating")
  var sleepQuality: Int = -1
)

Create the DAO

Open SleepDatabaseDao.kt in .database package.

@Dao
interface SleepDatabaseDao {
    @Insert
    fun insert(night: SleepNight)

    @Update
    fun update(night: SleepNight)

    @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
    fun get(key: Long): SleepNight?

    @Query("DELETE FROM daily_sleep_quality_table")
    fun clear()

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
    fun getTonight(): SleepNight?

    @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
    fun getAllNights(): LiveData<List<SleepNight>>
}

Note: @Get gets one item. @Delete deletes one item.

Create database

Open SleepDatabase.kt in .database package.

@Database(
  entities = [SleepNight::class],
  version = 1,
  exportSchema = false
)
abstract class SleepDatabase : RoomDatabase() {
  abstract val sleepDatabaseDao: SleepDatabaseDao

  companion object {
    // it makes up-to-date, no caching
    @Volatile
    private var INSTANCE: SleepDatabase? = null

    fun getInstance(context: Context): SleepDatabase {
      // guarantee the singleton instance
      synchronized(this) {
        var instance = INSTANCE
        if (instance == null) {
          instance = Room.databaseBuilder(
              context.applicationContext,
              SleepDatabase::class.java,
              "sleep_history_database"
            )
            .fallbackToDestructiveMigration()
            .build()
          INSTANCE = instance
        }
        return instance
      }
    }
  }
}

Test the database.

// SleepDatabaseTest.kt in androidTest
@RunWith(AndroidJUnit4::class)
class SleepDatabaseTest {

  private lateinit var sleepDao: SleepDatabaseDao
  private lateinit var db: SleepDatabase

  @Before
  fun createDb() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext
    // Using an in-memory database because the information stored here disappears when the
    // process is killed.
    db = Room.inMemoryDatabaseBuilder(context, SleepDatabase::class.java)
        // Allowing main thread queries, just for testing.
        .allowMainThreadQueries()
        .build()
    sleepDao = db.sleepDatabaseDao
  }

  @After
  @Throws(IOException::class)
  fun closeDb() {
    db.close()
  }

  @Test
  @Throws(Exception::class)
  fun insertAndGetNight() {
    val night = SleepNight()
    sleepDao.insert(night)
    val tonight = sleepDao.getTonight()
    assertEquals(tonight?.sleepQuality, -1)
  }
}

Run the test and check the result.

<merge> tag in layout

Eliminate redundant layouts when including layouts.

ViewModel with a database

// in SleepTrackerViewModel.kt
class SleepTrackerViewModel(
  val database: SleepDatabaseDao,
  application: Application) : AndroidViewModel(application) {
}
// in SleepTrackerViewModelFactory.kt
class SleepTrackerViewModelFactory(
    private val dataSource: SleepDatabaseDao,
    private val application: Application) : ViewModelProvider.Factory {
  @Suppress("unchecked_cast")
  override fun <T : ViewModel?> create(modelClass: Class<T>): T {
    if (modelClass.isAssignableFrom(SleepTrackerViewModel::class.java)) {
      return SleepTrackerViewModel(dataSource, application) as T
    }
    throw IllegalArgumentException("Unknown ViewModel class")
  }
}

Then, update the fragment.

// add after `binding` at `onCreateView()` in the fragment
// assemble the dependencies manually
val application = requireNotNull(this.activity).application
val dataSource = SleepDatabase.getInstance(application).sleepDatabaseDao
val viewModelFactory = SleepTrackerViewModelFactory(dataSource, application)
val sleepTrackerViewModel = ViewModelProvider(this, viewModelFactory)
        .get(SleepTrackerViewModel::class.java)

Add data binding for the viewModel at fragment xml file.

<data>
  <variable
    name="sleepTrackerViewModel"
    type="com.example.android.trackmysleepquality.sleeptracker.SleepTrackerViewModel" />
</data>

Register lifecycle owner of the binding and connect the viewModel.

// add after `binding` at `onCreateView()` in the fragment
binding.setLifecycleOwner(this)
binding.sleepTrackerViewModel = sleepTrackerViewModel

Coroutines

The logic can block the thread but coroutines make them suspend. It is good for long-running tasks because it is non-blocking and asynchronous via suspend.

  • Job: cancellable tasks
  • Dispatcher: Sends off coroutines to run on various threads
    • Dispatcher.Main
    • Dispatcher.IO
  • Scope: Defines the context in which the coroutine runs
    • CoroutineScope: Track all coroutines
      • ViewModelScope
      • LifecycleScope
      • liveData

Update DAO functions as suspend functions.

@Dao
interface SleepDatabaseDao {
  @Insert
  suspend fun insert(night: SleepNight)

  @Update
  suspend fun update(night: SleepNight)

  @Query("SELECT * from daily_sleep_quality_table WHERE nightId = :key")
  suspend fun get(key: Long): SleepNight?

  @Query("DELETE FROM daily_sleep_quality_table")
  suspend fun clear()

  @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC LIMIT 1")
  suspend fun getTonight(): SleepNight?

  @Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
  fun getAllNights(): LiveData<List<SleepNight>>
}

Add dependencies if needed.

// app level build.gradle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
// Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"

Init data from the ViewModel.

// SleepTrackerViewModel.kt
private var tonight = MutableLiveData<SleepNight?>()

init {
  initializeTonight()
}

private fun initializeTonight() {
  // Start coroutine in the `ViewModelScope`
  viewModelScope.launch {
    tonight.value = getTonightFromDatabase()
  }
}

private suspend fun getTonightFromDatabase(): SleepNight?
{
  var night = database.getTonight()
  if (night?.endTimeMilli != night?.startTimeMilli) {
    night = null
  }
  return night
}

Add click hander for the start button

// in the ViewModel
fun onStartTracking() {
  viewModelScope.launch { 
    val newNight = SleepNight()
    insert(newNight)
    tonight.value = getTonightFromDatabase()
  }
}

private suspend fun insert(night: SleepNight) {
  database.insert(night)
}

Then, add data binding on fragment xml file.

<Button
  android:id="@+id/start_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onStartTracking()}" />

Display the data

Room handles all LiveData magic. Transformation needed to show the data as an appropriated format.

// Util.kt
fun formatNights(nights: List<SleepNight>, resources: Resources): Spanned {
    val sb = StringBuilder()
    sb.apply {
        append(resources.getString(R.string.title))
        nights.forEach {
            append("<br>")
            append(resources.getString(R.string.start_time))
            append("\t${convertLongToDateString(it.startTimeMilli)}<br>")
            if (it.endTimeMilli != it.startTimeMilli) {
                append(resources.getString(R.string.end_time))
                append("\t${convertLongToDateString(it.endTimeMilli)}<br>")
                append(resources.getString(R.string.quality))
                append("\t${convertNumericQualityToString(it.sleepQuality, resources)}<br>")
                append(resources.getString(R.string.hours_slept))
                // Hours
                append("\t ${it.endTimeMilli.minus(it.startTimeMilli) / 1000 / 60 / 60}:")
                // Minutes
                append("${it.endTimeMilli.minus(it.startTimeMilli) / 1000 / 60}:")
                // Seconds
                append("${it.endTimeMilli.minus(it.startTimeMilli) / 1000}<br><br>")
            }
        }
    }
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        return Html.fromHtml(sb.toString(), Html.FROM_HTML_MODE_LEGACY)
    } else {
        return HtmlCompat.fromHtml(sb.toString(), HtmlCompat.FROM_HTML_MODE_LEGACY)
    }
}

Note: Spanned type is HTML-formatted string.

Add the member in the ViewModel so that returns formatted string.

// in the ViewModel
private val nights = database.getAllNights()

val nightsString = Transformations.map(nights) { nights ->
  formatNights(nights, application.resources)
}

Open the fragment xml file and add nightsString.

<TextView
  android:id="@+id/textview"
  ...
  android:text="@{sleepTrackerViewModel.nightsString}" />

Add the click handlers for the Stop and Clear button

// in the ViewModel
// for the stop button
fun onStopTracking() {
  viewModelScope.launch {
    val oldNight = tonight.value ?: return@launch
    oldNight.endTimeMilli = System.currentTimeMillis()
    update(oldNight)
  }
}

private suspend fun update(night: SleepNight) {
    database.update(night)
}

// for the clear button
fun onClear() {
   viewModelScope.launch {
       clear()
       tonight.value = null
   }
}

suspend fun clear() {
    database.clear()
}
<Button
  android:id="@+id/stop_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onStopTracking()}" />

...

<Button
  android:id="@+id/clear_button"
  ...
  android:onClick="@{() -> sleepTrackerViewModel.onClear()}" />

Pattern of the coroutine scopes

fun someWorkNeedsToBeDone {
  viewModelScope.launch {
    suspendFunction()
  }
}

suspend fun suspendFunction() {
  // Switch to the IO dispatcher
  withContext(Dispatchers.IO) {
    longrunningWork()
  }
}

// Using Room
fun someWorkNeedsToBeDone {
  viewModelScope.launch {
    suspendDAOFunction()
  }
}

suspend fun suspendDAOFunction() {
  // No need to specify the Dispatcher, Room uses Dispatchers.IO.
  longrunningDatabaseWork()
}

다음 챕터: Android Kotlin Fundamentals: Use LiveData to control button states

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.