From e7f0426eaa21039094d8a4d05cde41d2283f6c53 Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Wed, 29 Jan 2020 12:15:39 +0000 Subject: [PATCH 01/12] Having the list backed by network only --- .../android/codelabs/paging/Injection.kt | 19 ++----- .../codelabs/paging/data/GithubRepository.kt | 41 ++++++++++---- .../codelabs/paging/db/GithubLocalCache.kt | 55 ------------------- .../android/codelabs/paging/db/RepoDao.kt | 41 -------------- .../codelabs/paging/db/RepoDatabase.kt | 53 ------------------ .../android/codelabs/paging/model/Repo.kt | 7 ++- .../paging/ui/SearchRepositoriesActivity.kt | 4 +- 7 files changed, 42 insertions(+), 178 deletions(-) delete mode 100644 app/src/main/java/com/example/android/codelabs/paging/db/GithubLocalCache.kt delete mode 100644 app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt delete mode 100644 app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt diff --git a/app/src/main/java/com/example/android/codelabs/paging/Injection.kt b/app/src/main/java/com/example/android/codelabs/paging/Injection.kt index c67a1950..619d00c9 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/Injection.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/Injection.kt @@ -20,10 +20,7 @@ import android.content.Context import androidx.lifecycle.ViewModelProvider import com.example.android.codelabs.paging.api.GithubService import com.example.android.codelabs.paging.data.GithubRepository -import com.example.android.codelabs.paging.db.GithubLocalCache -import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.ui.ViewModelFactory -import java.util.concurrent.Executors /** * Class that handles object creation. @@ -32,27 +29,19 @@ import java.util.concurrent.Executors */ object Injection { - /** - * Creates an instance of [GithubLocalCache] based on the database DAO. - */ - private fun provideCache(context: Context): GithubLocalCache { - val database = RepoDatabase.getInstance(context) - return GithubLocalCache(database.reposDao(), Executors.newSingleThreadExecutor()) - } - /** * Creates an instance of [GithubRepository] based on the [GithubService] and a * [GithubLocalCache] */ - private fun provideGithubRepository(context: Context): GithubRepository { - return GithubRepository(GithubService.create(), provideCache(context)) + private fun provideGithubRepository(): GithubRepository { + return GithubRepository(GithubService.create()) } /** * Provides the [ViewModelProvider.Factory] that is then used to get a reference to * [ViewModel] objects. */ - fun provideViewModelFactory(context: Context): ViewModelProvider.Factory { - return ViewModelFactory(provideGithubRepository(context)) + fun provideViewModelFactory(): ViewModelProvider.Factory { + return ViewModelFactory(provideGithubRepository()) } } diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt index b1b9b6c4..c6c59e64 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt @@ -17,19 +17,21 @@ package com.example.android.codelabs.paging.data import android.util.Log +import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations import com.example.android.codelabs.paging.api.GithubService import com.example.android.codelabs.paging.api.searchRepos -import com.example.android.codelabs.paging.db.GithubLocalCache +import com.example.android.codelabs.paging.model.Repo import com.example.android.codelabs.paging.model.RepoSearchResult /** * Repository class that works with local and remote data sources. */ -class GithubRepository( - private val service: GithubService, - private val cache: GithubLocalCache -) { +class GithubRepository(private val service: GithubService) { + + // keep the list of responses + private val inMemoryCache = MutableLiveData>() // keep the last requested page. When the request is successful, increment the page number. private var lastRequestedPage = 1 @@ -48,8 +50,8 @@ class GithubRepository( lastRequestedPage = 1 requestAndSaveData(query) - // Get data from the local cache - val data = cache.reposByName(query) + // Get data from the in memory cache + val data = reposByName(query) return RepoSearchResult(data, networkErrors) } @@ -63,16 +65,33 @@ class GithubRepository( isRequestInProgress = true searchRepos(service, query, lastRequestedPage, NETWORK_PAGE_SIZE, { repos -> - cache.insert(repos) { - lastRequestedPage++ - isRequestInProgress = false - } + // add the new result list to the existing list + val allResults = mutableListOf() + inMemoryCache.value?.let { allResults.addAll(it) } + allResults.addAll(repos) + + inMemoryCache.postValue(allResults) + lastRequestedPage++ + isRequestInProgress = false }, { error -> networkErrors.postValue(error) isRequestInProgress = false }) } + private fun reposByName(query: String): LiveData> { + return Transformations.switchMap(inMemoryCache) { repos -> + // from the in memory cache select only the repos whose name or description matches + // the query. Then order the results. + val filteredList = repos.filter { + it.name.contains(query, true) || + (it.description != null && it.description.contains(query, true)) + }.sortedWith(compareByDescending { it.stars }.thenBy { it.name }) + + MutableLiveData(filteredList) + } + } + companion object { private const val NETWORK_PAGE_SIZE = 50 } diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/GithubLocalCache.kt b/app/src/main/java/com/example/android/codelabs/paging/db/GithubLocalCache.kt deleted file mode 100644 index e427134f..00000000 --- a/app/src/main/java/com/example/android/codelabs/paging/db/GithubLocalCache.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.android.codelabs.paging.db - -import android.util.Log -import androidx.lifecycle.LiveData -import com.example.android.codelabs.paging.model.Repo -import java.util.concurrent.Executor - -/** - * Class that handles the DAO local data source. This ensures that methods are triggered on the - * correct executor. - */ -class GithubLocalCache( - private val repoDao: RepoDao, - private val ioExecutor: Executor -) { - - /** - * Insert a list of repos in the database, on a background thread. - */ - fun insert(repos: List, insertFinished: () -> Unit) { - ioExecutor.execute { - Log.d("GithubLocalCache", "inserting ${repos.size} repos") - repoDao.insert(repos) - insertFinished() - } - } - - /** - * Request a LiveData> from the Dao, based on a repo name. If the name contains - * multiple words separated by spaces, then we're emulating the GitHub API behavior and allow - * any characters between the words. - * @param name repository name - */ - fun reposByName(name: String): LiveData> { - // appending '%' so we can allow other characters to be before and after the query string - val query = "%${name.replace(' ', '%')}%" - return repoDao.reposByName(query) - } -} diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt deleted file mode 100644 index 60665fad..00000000 --- a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.android.codelabs.paging.db - -import androidx.lifecycle.LiveData -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.example.android.codelabs.paging.model.Repo - -/** - * Room data access object for accessing the [Repo] table. - */ -@Dao -interface RepoDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insert(posts: List) - - // Do a similar query as the search API: - // Look for repos that contain the query string in the name or in the description - // and order those results descending, by the number of stars and then by name - @Query("SELECT * FROM repos WHERE (name LIKE :queryString) OR (description LIKE " + - ":queryString) ORDER BY stars DESC, name ASC") - fun reposByName(queryString: String): LiveData> -} diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt deleted file mode 100644 index 2c5db0c0..00000000 --- a/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2018 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.android.codelabs.paging.db - -import androidx.room.Database -import androidx.room.Room -import androidx.room.RoomDatabase -import android.content.Context -import com.example.android.codelabs.paging.model.Repo - -/** - * Database schema that holds the list of repos. - */ -@Database( - entities = [Repo::class], - version = 1, - exportSchema = false -) -abstract class RepoDatabase : RoomDatabase() { - - abstract fun reposDao(): RepoDao - - companion object { - - @Volatile - private var INSTANCE: RepoDatabase? = null - - fun getInstance(context: Context): RepoDatabase = - INSTANCE ?: synchronized(this) { - INSTANCE - ?: buildDatabase(context).also { INSTANCE = it } - } - - private fun buildDatabase(context: Context) = - Room.databaseBuilder(context.applicationContext, - RepoDatabase::class.java, "Github.db") - .build() - } -} diff --git a/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt b/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt index 87fbc41a..36f0fa59 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt @@ -36,4 +36,9 @@ data class Repo( @field:SerializedName("stargazers_count") val stars: Int, @field:SerializedName("forks_count") val forks: Int, @field:SerializedName("language") val language: String? -) +){ + + override fun toString(): String { + return "Repo(name='$name', stars=$stars)" + } +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index 53029c1c..acb85977 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -24,7 +24,7 @@ import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.DividerItemDecoration import com.example.android.codelabs.paging.Injection import com.example.android.codelabs.paging.R @@ -41,7 +41,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { setContentView(R.layout.activity_search_repositories) // get the view model - viewModel = ViewModelProviders.of(this, Injection.provideViewModelFactory(this)) + viewModel = ViewModelProvider(this, Injection.provideViewModelFactory()) .get(SearchRepositoriesViewModel::class.java) // add dividers between RecyclerView's row items From 217bf722f061789dfd517842edfd67f97745cd65 Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Wed, 29 Jan 2020 18:49:23 +0000 Subject: [PATCH 02/12] Showing an infinite scrolling list, from network, with Flow --- app/build.gradle | 4 ++ .../codelabs/paging/api/GithubService.kt | 66 +++++++------------ .../codelabs/paging/data/GithubRepository.kt | 66 +++++++++---------- .../android/codelabs/paging/model/Repo.kt | 7 +- .../codelabs/paging/model/RepoSearchResult.kt | 10 ++- .../paging/ui/SearchRepositoriesActivity.kt | 20 +++--- .../paging/ui/SearchRepositoriesViewModel.kt | 27 ++++---- build.gradle | 9 +-- 8 files changed, 95 insertions(+), 114 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 7960af71..33324e21 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -40,6 +40,8 @@ android { dependencies { implementation fileTree(dir: 'libs') implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation"org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines" + implementation"org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines" implementation "androidx.appcompat:appcompat:$supportLibVersion" implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" implementation "com.google.android.material:material:$materialVersion" @@ -47,6 +49,8 @@ dependencies { // architecture components implementation "androidx.lifecycle:lifecycle-extensions:$archComponentsVersion" implementation "androidx.lifecycle:lifecycle-runtime:$archComponentsVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$archComponentsVersion" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$archComponentsVersion" implementation "androidx.room:room-runtime:$roomVersion" implementation "androidx.paging:paging-runtime:$pagingVersion" kapt "androidx.lifecycle:lifecycle-compiler:$archComponentsVersion" diff --git a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt index 91114950..041ddda9 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt @@ -17,12 +17,10 @@ package com.example.android.codelabs.paging.api import android.util.Log -import com.example.android.codelabs.paging.model.Repo +import com.example.android.codelabs.paging.model.RepoSearchResult import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor.Level -import retrofit2.Call -import retrofit2.Callback import retrofit2.Response import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory @@ -38,44 +36,30 @@ private const val IN_QUALIFIER = "in:name,description" * @param query searchRepo keyword * @param page request page index * @param itemsPerPage number of repositories to be returned by the Github API per page - * - * The result of the request is handled by the implementation of the functions passed as params - * @param onSuccess function that defines how to handle the list of repos received - * @param onError function that defines how to handle request failure */ -fun searchRepos( - service: GithubService, - query: String, - page: Int, - itemsPerPage: Int, - onSuccess: (repos: List) -> Unit, - onError: (error: String) -> Unit -) { +suspend fun searchRepos( + service: GithubService, + query: String, + page: Int, + itemsPerPage: Int +): RepoSearchResult { Log.d(TAG, "query: $query, page: $page, itemsPerPage: $itemsPerPage") val apiQuery = query + IN_QUALIFIER - service.searchRepos(apiQuery, page, itemsPerPage).enqueue( - object : Callback { - override fun onFailure(call: Call?, t: Throwable) { - Log.d(TAG, "fail to get data") - onError(t.message ?: "unknown error") - } - - override fun onResponse( - call: Call?, - response: Response - ) { - Log.d(TAG, "got a response $response") - if (response.isSuccessful) { - val repos = response.body()?.items ?: emptyList() - onSuccess(repos) - } else { - onError(response.errorBody()?.string() ?: "Unknown error") - } - } - } - ) + val response = service.searchRepos(apiQuery, page, itemsPerPage) + if (response.isSuccessful) { + Log.d(TAG, "got a response $response") + if (response.isSuccessful) { + val repos = response.body()?.items ?: emptyList() + return RepoSearchResult(repos) + } else { + return RepoSearchResult(emptyList(), response.message() ?: "Unknown error") + } + } else { + Log.d(TAG, "fail to get data") + return RepoSearchResult(emptyList(), response.message() ?: "Unknown error") + } } /** @@ -86,11 +70,11 @@ interface GithubService { * Get repos ordered by stars. */ @GET("search/repositories?sort=stars") - fun searchRepos( - @Query("q") query: String, - @Query("page") page: Int, - @Query("per_page") itemsPerPage: Int - ): Call + suspend fun searchRepos( + @Query("q") query: String, + @Query("page") page: Int, + @Query("per_page") itemsPerPage: Int + ): Response companion object { private const val BASE_URL = "https://api.github.com/" diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt index c6c59e64..f73c84c0 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt @@ -17,79 +17,75 @@ package com.example.android.codelabs.paging.data import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations import com.example.android.codelabs.paging.api.GithubService import com.example.android.codelabs.paging.api.searchRepos import com.example.android.codelabs.paging.model.Repo import com.example.android.codelabs.paging.model.RepoSearchResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow /** * Repository class that works with local and remote data sources. */ +@FlowPreview +@ExperimentalCoroutinesApi class GithubRepository(private val service: GithubService) { // keep the list of responses - private val inMemoryCache = MutableLiveData>() + private val inMemoryCache = ConflatedBroadcastChannel() // keep the last requested page. When the request is successful, increment the page number. private var lastRequestedPage = 1 - // LiveData of network errors. - private val networkErrors = MutableLiveData() - // avoid triggering multiple requests in the same time private var isRequestInProgress = false /** * Search repositories whose names match the query. */ - fun search(query: String): RepoSearchResult { + suspend fun search(query: String): Flow { Log.d("GithubRepository", "New query: $query") lastRequestedPage = 1 requestAndSaveData(query) // Get data from the in memory cache val data = reposByName(query) - - return RepoSearchResult(data, networkErrors) + inMemoryCache.offer(RepoSearchResult(data)) + return inMemoryCache.asFlow() } - fun requestMore(query: String) { + suspend fun requestMore(query: String) { requestAndSaveData(query) } - private fun requestAndSaveData(query: String) { + private suspend fun requestAndSaveData(query: String) { if (isRequestInProgress) return isRequestInProgress = true - searchRepos(service, query, lastRequestedPage, NETWORK_PAGE_SIZE, { repos -> - // add the new result list to the existing list - val allResults = mutableListOf() - inMemoryCache.value?.let { allResults.addAll(it) } - allResults.addAll(repos) + val apiResponse = searchRepos(service, query, lastRequestedPage, NETWORK_PAGE_SIZE) + Log.d("GithubRepository", "response $apiResponse") + // add the new result list to the existing list + val allResults = mutableListOf() + inMemoryCache.valueOrNull?.let { allResults.addAll(it.data) } + allResults.addAll(apiResponse.data) - inMemoryCache.postValue(allResults) - lastRequestedPage++ - isRequestInProgress = false - }, { error -> - networkErrors.postValue(error) - isRequestInProgress = false - }) - } + inMemoryCache.offer(RepoSearchResult(allResults, apiResponse.networkErrors)) - private fun reposByName(query: String): LiveData> { - return Transformations.switchMap(inMemoryCache) { repos -> - // from the in memory cache select only the repos whose name or description matches - // the query. Then order the results. - val filteredList = repos.filter { - it.name.contains(query, true) || - (it.description != null && it.description.contains(query, true)) - }.sortedWith(compareByDescending { it.stars }.thenBy { it.name }) + lastRequestedPage++ + isRequestInProgress = false + } - MutableLiveData(filteredList) - } + private fun reposByName(query: String): List { + // from the in memory cache select only the repos whose name or description matches + // the query. Then order the results. + val result = inMemoryCache.valueOrNull ?: return emptyList() + return result.data.filter { + it.name.contains(query, true) || + (it.description != null && it.description.contains(query, true)) + }.sortedWith(compareByDescending { it.stars }.thenBy { it.name }) } companion object { diff --git a/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt b/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt index 36f0fa59..87fbc41a 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/model/Repo.kt @@ -36,9 +36,4 @@ data class Repo( @field:SerializedName("stargazers_count") val stars: Int, @field:SerializedName("forks_count") val forks: Int, @field:SerializedName("language") val language: String? -){ - - override fun toString(): String { - return "Repo(name='$name', stars=$stars)" - } -} +) diff --git a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt b/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt index 75688b35..2c5ed6d1 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt @@ -16,13 +16,11 @@ package com.example.android.codelabs.paging.model -import androidx.lifecycle.LiveData - /** - * RepoSearchResult from a search, which contains LiveData> holding query data, - * and a LiveData of network error state. + * RepoSearchResult from a search, which contains List holding query data, + * and a String of network error state. */ data class RepoSearchResult( - val data: LiveData>, - val networkErrors: LiveData + val data: List, + val networkErrors: String? = null ) diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index acb85977..c76d6390 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -28,9 +28,13 @@ import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.DividerItemDecoration import com.example.android.codelabs.paging.Injection import com.example.android.codelabs.paging.R -import com.example.android.codelabs.paging.model.Repo +import com.example.android.codelabs.paging.model.RepoSearchResult import kotlinx.android.synthetic.main.activity_search_repositories.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +@FlowPreview +@ExperimentalCoroutinesApi class SearchRepositoriesActivity : AppCompatActivity() { private lateinit var viewModel: SearchRepositoriesViewModel @@ -62,13 +66,13 @@ class SearchRepositoriesActivity : AppCompatActivity() { private fun initAdapter() { list.adapter = adapter - viewModel.repos.observe(this, Observer> { - Log.d("Activity", "list: ${it?.size}") - showEmptyList(it?.size == 0) - adapter.submitList(it) - }) - viewModel.networkErrors.observe(this, Observer { - Toast.makeText(this, "\uD83D\uDE28 Wooops $it", Toast.LENGTH_LONG).show() + viewModel.repoResult.observe(this, Observer { + Log.d("Activity", "list: ${it.data.size}") + showEmptyList(it.data.isEmpty()) + adapter.submitList(it.data) + it.networkErrors?.let { + Toast.makeText(this, "\uD83D\uDE28 Wooops $it", Toast.LENGTH_LONG).show() + } }) } diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt index 4034f91e..21898e90 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt @@ -16,18 +16,17 @@ package com.example.android.codelabs.paging.ui -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations -import androidx.lifecycle.ViewModel +import androidx.lifecycle.* import com.example.android.codelabs.paging.data.GithubRepository -import com.example.android.codelabs.paging.model.Repo import com.example.android.codelabs.paging.model.RepoSearchResult +import kotlinx.coroutines.* /** * ViewModel for the [SearchRepositoriesActivity] screen. * The ViewModel works with the [GithubRepository] to get the data. */ +@ExperimentalCoroutinesApi +@FlowPreview class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() { companion object { @@ -35,13 +34,11 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi } private val queryLiveData = MutableLiveData() - private val repoResult: LiveData = Transformations.map(queryLiveData) { - repository.search(it) - } - - val repos: LiveData> = Transformations.switchMap(repoResult) { it -> it.data } - val networkErrors: LiveData = Transformations.switchMap(repoResult) { it -> - it.networkErrors + val repoResult: LiveData = Transformations.switchMap(queryLiveData) { + liveData { + val repos = repository.search(it).asLiveData(Dispatchers.Main) + emitSource(repos) + } } /** @@ -55,7 +52,9 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi if (visibleItemCount + lastVisibleItemPosition + VISIBLE_THRESHOLD >= totalItemCount) { val immutableQuery = lastQueryValue() if (immutableQuery != null) { - repository.requestMore(immutableQuery) + viewModelScope.launch { + repository.requestMore(immutableQuery) + } } } } @@ -64,4 +63,4 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi * Get the last query value. */ fun lastQueryValue(): String? = queryLiveData.value -} +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index a13286c6..22f1293b 100644 --- a/build.gradle +++ b/build.gradle @@ -52,11 +52,12 @@ ext { archComponentsVersion = '2.2.0' roomVersion = '2.2.3' pagingVersion = '2.1.0-alpha01' - retrofitVersion = "2.6.0" - okhttpLoggingInterceptorVersion = "4.0.0" + retrofitVersion = '2.6.0' + okhttpLoggingInterceptorVersion = '4.0.0' + coroutines = '1.3.0' runnerVersion = '1.3.0-alpha03' - rulesVersion = "1.0.1" - junitVersion = "4.12" + rulesVersion = '1.0.1' + junitVersion = '4.12' espressoVersion = '3.3.0-alpha03' } From ee2f556e1578cd2b66102b40691aa15857509f7b Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Fri, 31 Jan 2020 14:45:13 +0000 Subject: [PATCH 03/12] Using more KTX functionality and making the error an exception instead of string --- app/build.gradle | 4 ++ .../codelabs/paging/api/GithubService.kt | 5 ++- .../codelabs/paging/data/GithubRepository.kt | 37 +++++++++++-------- .../codelabs/paging/model/RepoSearchResult.kt | 10 +++-- .../paging/ui/SearchRepositoriesActivity.kt | 22 +++++++---- .../paging/ui/SearchRepositoriesViewModel.kt | 6 +-- 6 files changed, 52 insertions(+), 32 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 33324e21..8eb21979 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -35,6 +35,10 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + + kotlinOptions { + jvmTarget = "1.8" + } } dependencies { diff --git a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt index 041ddda9..2de4d97b 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt @@ -26,6 +26,7 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.http.GET import retrofit2.http.Query +import java.io.IOException private const val TAG = "GithubService" private const val IN_QUALIFIER = "in:name,description" @@ -54,11 +55,11 @@ suspend fun searchRepos( val repos = response.body()?.items ?: emptyList() return RepoSearchResult(repos) } else { - return RepoSearchResult(emptyList(), response.message() ?: "Unknown error") + return RepoSearchResult(emptyList(), IOException(response.message() ?: "Unknown error")) } } else { Log.d(TAG, "fail to get data") - return RepoSearchResult(emptyList(), response.message() ?: "Unknown error") + return RepoSearchResult(emptyList(), IOException(response.message() ?: "Unknown error")) } } diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt index f73c84c0..991a2299 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt @@ -34,8 +34,12 @@ import kotlinx.coroutines.flow.asFlow @ExperimentalCoroutinesApi class GithubRepository(private val service: GithubService) { - // keep the list of responses - private val inMemoryCache = ConflatedBroadcastChannel() + // keep the list of all results received + private val inMemoryCache = mutableListOf() + + // keep channel of results. The channel allows us to broadcast updates so + // the subscriber will have the latest data + private val searchResults = ConflatedBroadcastChannel() // keep the last requested page. When the request is successful, increment the page number. private var lastRequestedPage = 1 @@ -44,17 +48,15 @@ class GithubRepository(private val service: GithubService) { private var isRequestInProgress = false /** - * Search repositories whose names match the query. + * Search repositories whose names match the query, exposed as a stream of data that will emit + * every time we get more data from the network. */ - suspend fun search(query: String): Flow { + suspend fun getSearchResultStream(query: String): Flow { Log.d("GithubRepository", "New query: $query") lastRequestedPage = 1 requestAndSaveData(query) - // Get data from the in memory cache - val data = reposByName(query) - inMemoryCache.offer(RepoSearchResult(data)) - return inMemoryCache.asFlow() + return searchResults.asFlow() } suspend fun requestMore(query: String) { @@ -68,12 +70,16 @@ class GithubRepository(private val service: GithubService) { val apiResponse = searchRepos(service, query, lastRequestedPage, NETWORK_PAGE_SIZE) Log.d("GithubRepository", "response $apiResponse") // add the new result list to the existing list - val allResults = mutableListOf() - inMemoryCache.valueOrNull?.let { allResults.addAll(it.data) } - allResults.addAll(apiResponse.data) - - inMemoryCache.offer(RepoSearchResult(allResults, apiResponse.networkErrors)) - + when (apiResponse) { + is RepoSearchResult.Success -> { + inMemoryCache.addAll(apiResponse.data) + val reposByName = reposByName(query) + searchResults.offer(RepoSearchResult.Success(reposByName)) + } + is RepoSearchResult.Error -> { + searchResults.offer(RepoSearchResult.Error(apiResponse.error)) + } + } lastRequestedPage++ isRequestInProgress = false } @@ -81,8 +87,7 @@ class GithubRepository(private val service: GithubService) { private fun reposByName(query: String): List { // from the in memory cache select only the repos whose name or description matches // the query. Then order the results. - val result = inMemoryCache.valueOrNull ?: return emptyList() - return result.data.filter { + return inMemoryCache.filter { it.name.contains(query, true) || (it.description != null && it.description.contains(query, true)) }.sortedWith(compareByDescending { it.stars }.thenBy { it.name }) diff --git a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt b/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt index 2c5ed6d1..d00c1f07 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/model/RepoSearchResult.kt @@ -16,11 +16,13 @@ package com.example.android.codelabs.paging.model +import java.lang.Exception + /** * RepoSearchResult from a search, which contains List holding query data, * and a String of network error state. */ -data class RepoSearchResult( - val data: List, - val networkErrors: String? = null -) +sealed class RepoSearchResult { + data class Success(val data: List) : RepoSearchResult() + data class Error(val error: Exception) : RepoSearchResult() +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index c76d6390..5017df5b 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -25,6 +25,7 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.observe import androidx.recyclerview.widget.DividerItemDecoration import com.example.android.codelabs.paging.Injection import com.example.android.codelabs.paging.R @@ -66,14 +67,21 @@ class SearchRepositoriesActivity : AppCompatActivity() { private fun initAdapter() { list.adapter = adapter - viewModel.repoResult.observe(this, Observer { - Log.d("Activity", "list: ${it.data.size}") - showEmptyList(it.data.isEmpty()) - adapter.submitList(it.data) - it.networkErrors?.let { - Toast.makeText(this, "\uD83D\uDE28 Wooops $it", Toast.LENGTH_LONG).show() + viewModel.repoResult.observe(this) { result -> + when (result) { + is RepoSearchResult.Success -> { + showEmptyList(result.data.isEmpty()) + adapter.submitList(result.data) + } + is RepoSearchResult.Error -> { + Toast.makeText( + this, + "\uD83D\uDE28 Wooops $result.message}", + Toast.LENGTH_LONG + ).show() + } } - }) + } } private fun initSearch(query: String) { diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt index 21898e90..5fb327a9 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt @@ -34,9 +34,9 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi } private val queryLiveData = MutableLiveData() - val repoResult: LiveData = Transformations.switchMap(queryLiveData) { - liveData { - val repos = repository.search(it).asLiveData(Dispatchers.Main) + val repoResult: LiveData = queryLiveData.switchMap { + liveData { + val repos = repository.getSearchResultStream(it).asLiveData(Dispatchers.Main) emitSource(repos) } } From 49c554ee740920c920bef41fba36a69e234b4232 Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Mon, 24 Feb 2020 15:30:38 +0000 Subject: [PATCH 04/12] Adding ViewBinding --- app/build.gradle | 4 ++ .../codelabs/paging/api/GithubService.kt | 6 +-- .../paging/ui/SearchRepositoriesActivity.kt | 43 ++++++++++--------- .../paging/ui/SearchRepositoriesViewModel.kt | 7 +-- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 8eb21979..03bb3552 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -39,6 +39,10 @@ android { kotlinOptions { jvmTarget = "1.8" } + + buildFeatures { + viewBinding = true + } } dependencies { diff --git a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt index 2de4d97b..4f40ecb3 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/api/GithubService.kt @@ -53,13 +53,13 @@ suspend fun searchRepos( Log.d(TAG, "got a response $response") if (response.isSuccessful) { val repos = response.body()?.items ?: emptyList() - return RepoSearchResult(repos) + return RepoSearchResult.Success(repos) } else { - return RepoSearchResult(emptyList(), IOException(response.message() ?: "Unknown error")) + return RepoSearchResult.Error(IOException(response.message() ?: "Unknown error")) } } else { Log.d(TAG, "fail to get data") - return RepoSearchResult(emptyList(), IOException(response.message() ?: "Unknown error")) + return RepoSearchResult.Error(IOException(response.message() ?: "Unknown error")) } } diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index 5017df5b..bef1f07b 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -17,20 +17,20 @@ package com.example.android.codelabs.paging.ui import android.os.Bundle -import android.util.Log import android.view.KeyEvent import android.view.View import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.observe import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.OnScrollListener import com.example.android.codelabs.paging.Injection -import com.example.android.codelabs.paging.R +import com.example.android.codelabs.paging.databinding.ActivitySearchRepositoriesBinding import com.example.android.codelabs.paging.model.RepoSearchResult -import kotlinx.android.synthetic.main.activity_search_repositories.* import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -38,12 +38,15 @@ import kotlinx.coroutines.FlowPreview @ExperimentalCoroutinesApi class SearchRepositoriesActivity : AppCompatActivity() { + private lateinit var binding: ActivitySearchRepositoriesBinding private lateinit var viewModel: SearchRepositoriesViewModel private val adapter = ReposAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_search_repositories) + binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) // get the view model viewModel = ViewModelProvider(this, Injection.provideViewModelFactory()) @@ -51,7 +54,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { // add dividers between RecyclerView's row items val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) - list.addItemDecoration(decoration) + binding.list.addItemDecoration(decoration) setupScrollListener() initAdapter() @@ -62,11 +65,11 @@ class SearchRepositoriesActivity : AppCompatActivity() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString(LAST_SEARCH_QUERY, viewModel.lastQueryValue()) + outState.putString(LAST_SEARCH_QUERY, binding.searchRepo.text.trim().toString()) } private fun initAdapter() { - list.adapter = adapter + binding.list.adapter = adapter viewModel.repoResult.observe(this) { result -> when (result) { is RepoSearchResult.Success -> { @@ -85,9 +88,9 @@ class SearchRepositoriesActivity : AppCompatActivity() { } private fun initSearch(query: String) { - search_repo.setText(query) + binding.searchRepo.setText(query) - search_repo.setOnEditorActionListener { _, actionId, _ -> + binding.searchRepo.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_GO) { updateRepoListFromInput() true @@ -95,7 +98,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { false } } - search_repo.setOnKeyListener { _, keyCode, event -> + binding.searchRepo.setOnKeyListener { _, keyCode, event -> if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER) { updateRepoListFromInput() true @@ -106,9 +109,9 @@ class SearchRepositoriesActivity : AppCompatActivity() { } private fun updateRepoListFromInput() { - search_repo.text.trim().let { + binding.searchRepo.text.trim().let { if (it.isNotEmpty()) { - list.scrollToPosition(0) + binding.list.scrollToPosition(0) viewModel.searchRepo(it.toString()) adapter.submitList(null) } @@ -117,18 +120,18 @@ class SearchRepositoriesActivity : AppCompatActivity() { private fun showEmptyList(show: Boolean) { if (show) { - emptyList.visibility = View.VISIBLE - list.visibility = View.GONE + binding.emptyList.visibility = View.VISIBLE + binding.list.visibility = View.GONE } else { - emptyList.visibility = View.GONE - list.visibility = View.VISIBLE + binding.emptyList.visibility = View.GONE + binding.list.visibility = View.VISIBLE } } private fun setupScrollListener() { - val layoutManager = list.layoutManager as androidx.recyclerview.widget.LinearLayoutManager - list.addOnScrollListener(object : androidx.recyclerview.widget.RecyclerView.OnScrollListener() { - override fun onScrolled(recyclerView: androidx.recyclerview.widget.RecyclerView, dx: Int, dy: Int) { + val layoutManager = binding.list.layoutManager as LinearLayoutManager + binding.list.addOnScrollListener(object : OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) val totalItemCount = layoutManager.itemCount val visibleItemCount = layoutManager.childCount diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt index 5fb327a9..e7f893a3 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt @@ -50,7 +50,7 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi fun listScrolled(visibleItemCount: Int, lastVisibleItemPosition: Int, totalItemCount: Int) { if (visibleItemCount + lastVisibleItemPosition + VISIBLE_THRESHOLD >= totalItemCount) { - val immutableQuery = lastQueryValue() + val immutableQuery = queryLiveData.value if (immutableQuery != null) { viewModelScope.launch { repository.requestMore(immutableQuery) @@ -58,9 +58,4 @@ class SearchRepositoriesViewModel(private val repository: GithubRepository) : Vi } } } - - /** - * Get the last query value. - */ - fun lastQueryValue(): String? = queryLiveData.value } \ No newline at end of file From 12de5eefaf40d5c1e83c041e05e5a957f26bfa85 Mon Sep 17 00:00:00 2001 From: Florina Muntenescu Date: Fri, 28 Feb 2020 12:19:10 +0000 Subject: [PATCH 05/12] Making the UI more material --- app/src/main/res/layout/activity_search_repositories.xml | 2 ++ app/src/main/res/values/colors.xml | 2 ++ app/src/main/res/values/styles.xml | 2 +- build.gradle | 4 ++-- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/activity_search_repositories.xml b/app/src/main/res/layout/activity_search_repositories.xml index fbeef8b2..738040a3 100644 --- a/app/src/main/res/layout/activity_search_repositories.xml +++ b/app/src/main/res/layout/activity_search_repositories.xml @@ -31,6 +31,7 @@ android:layout_marginTop="8dp" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" + style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> @@ -41,6 +42,7 @@ android:hint="@string/search_hint" android:imeOptions="actionSearch" android:inputType="textNoSuggestions" + android:selectAllOnFocus="true" tools:text="Android"/> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 066c3cc0..de598ecb 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -22,4 +22,6 @@ #1F75FE #551F75FE #55999999 + #AAAAAA + #FFFFFF diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d0c539d1..dd6eb3f8 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -17,7 +17,7 @@ -