diff --git a/app/build.gradle b/app/build.gradle index 7ad34dd9..8d7d5419 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -67,8 +67,8 @@ dependencies { implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.room:room-runtime:$roomVersion" + implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.paging:paging-runtime:$pagingVersion" - kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion" kapt "androidx.room:room-compiler:$roomVersion" // retrofit 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 619d00c9..1c435668 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,6 +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.RepoDatabase import com.example.android.codelabs.paging.ui.ViewModelFactory /** @@ -33,15 +34,15 @@ object Injection { * Creates an instance of [GithubRepository] based on the [GithubService] and a * [GithubLocalCache] */ - private fun provideGithubRepository(): GithubRepository { - return GithubRepository(GithubService.create()) + private fun provideGithubRepository(context: Context): GithubRepository { + return GithubRepository(GithubService.create(), RepoDatabase.getInstance(context)) } /** * Provides the [ViewModelProvider.Factory] that is then used to get a reference to * [ViewModel] objects. */ - fun provideViewModelFactory(): ViewModelProvider.Factory { - return ViewModelFactory(provideGithubRepository()) + fun provideViewModelFactory(context: Context): ViewModelProvider.Factory { + return ViewModelFactory(provideGithubRepository(context)) } } 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 eb36bddc..1d9180e3 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 @@ -39,7 +39,7 @@ interface GithubService { @Query("q") query: String, @Query("page") page: Int, @Query("per_page") itemsPerPage: Int - ): Response + ): RepoSearchResponse companion object { private const val BASE_URL = "https://api.github.com/" diff --git a/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt new file mode 100644 index 00000000..efb34176 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2020 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.data + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.room.withTransaction +import com.example.android.codelabs.paging.api.GithubService +import com.example.android.codelabs.paging.api.IN_QUALIFIER +import com.example.android.codelabs.paging.db.RemoteKeys +import com.example.android.codelabs.paging.db.RepoDatabase +import com.example.android.codelabs.paging.model.Repo +import retrofit2.HttpException +import java.io.IOException +import java.io.InvalidObjectException + +// GitHub page API is 1 based: https://developer.github.com/v3/#pagination +private const val GITHUB_STARTING_PAGE_INDEX = 1 + +@OptIn(ExperimentalPagingApi::class) +class GithubRemoteMediator( + private val query: String, + private val service: GithubService, + private val repoDatabase: RepoDatabase +) : RemoteMediator() { + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + + val page = when (loadType) { + LoadType.REFRESH -> { + val remoteKeys = getRemoteKeyClosestToCurrentPosition(state) + remoteKeys?.nextKey?.minus(1) ?: GITHUB_STARTING_PAGE_INDEX + } + LoadType.PREPEND -> { + val remoteKeys = getRemoteKeyForFirstItem(state) + if (remoteKeys == null) { + // The LoadType is PREPEND so some data was loaded before, + // so we should have been able to get remote keys + // If the remoteKeys are null, then we're an invalid state and we have a bug + throw InvalidObjectException("Remote key and the prevKey should not be null") + } + // If the previous key is null, then we can't request more data + val prevKey = remoteKeys.prevKey + if (prevKey == null) { + return MediatorResult.Success(endOfPaginationReached = true) + } + remoteKeys.prevKey + } + LoadType.APPEND -> { + val remoteKeys = getRemoteKeyForLastItem(state) + if (remoteKeys == null || remoteKeys.nextKey == null) { + throw InvalidObjectException("Remote key should not be null for $loadType") + } + remoteKeys.nextKey + } + + } + + val apiQuery = query + IN_QUALIFIER + + try { + val apiResponse = service.searchRepos(apiQuery, page, state.config.pageSize) + + val repos = apiResponse.items + val endOfPaginationReached = repos.isEmpty() + repoDatabase.withTransaction { + // clear all tables in the database + if (loadType == LoadType.REFRESH) { + repoDatabase.remoteKeysDao().clearRemoteKeys() + repoDatabase.reposDao().clearRepos() + } + val prevKey = if (page == GITHUB_STARTING_PAGE_INDEX) null else page - 1 + val nextKey = if (endOfPaginationReached) null else page + 1 + val keys = repos.map { + RemoteKeys(repoId = it.id, prevKey = prevKey, nextKey = nextKey) + } + repoDatabase.remoteKeysDao().insertAll(keys) + repoDatabase.reposDao().insertAll(repos) + } + return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) + } catch (exception: IOException) { + return MediatorResult.Error(exception) + } catch (exception: HttpException) { + return MediatorResult.Error(exception) + } + } + + private suspend fun getRemoteKeyForLastItem(state: PagingState): RemoteKeys? { + // Get the last page that was retrieved, that contained items. + // From that last page, get the last item + return state.pages.lastOrNull() { it.data.isNotEmpty() }?.data?.lastOrNull() + ?.let { repo -> + // Get the remote keys of the last item retrieved + repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id) + } + } + + private suspend fun getRemoteKeyForFirstItem(state: PagingState): RemoteKeys? { + // Get the first page that was retrieved, that contained items. + // From that first page, get the first item + return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull() + ?.let { repo -> + // Get the remote keys of the first items retrieved + repoDatabase.remoteKeysDao().remoteKeysRepoId(repo.id) + } + } + + private suspend fun getRemoteKeyClosestToCurrentPosition( + state: PagingState + ): RemoteKeys? { + // The paging library is trying to load data after the anchor position + // Get the item closest to the anchor position + return state.anchorPosition?.let { position -> + state.closestItemToPosition(position)?.id?.let { repoId -> + repoDatabase.remoteKeysDao().remoteKeysRepoId(repoId) + } + } + } + +} \ No newline at end of file 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 7ebed906..bc314964 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,107 +17,43 @@ package com.example.android.codelabs.paging.data import android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData import com.example.android.codelabs.paging.api.GithubService -import com.example.android.codelabs.paging.api.IN_QUALIFIER +import com.example.android.codelabs.paging.db.RepoDatabase 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 -import retrofit2.HttpException -import java.io.IOException - -// GitHub page API is 1 based: https://developer.github.com/v3/#pagination -private const val GITHUB_STARTING_PAGE_INDEX = 1 /** * Repository class that works with local and remote data sources. */ -@ExperimentalCoroutinesApi -class GithubRepository(private val service: GithubService) { - - // 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 = GITHUB_STARTING_PAGE_INDEX - - // avoid triggering multiple requests in the same time - private var isRequestInProgress = false +class GithubRepository( + private val service: GithubService, + private val database: RepoDatabase +) { /** * 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 getSearchResultStream(query: String): Flow { + fun getSearchResultStream(query: String): Flow> { Log.d("GithubRepository", "New query: $query") - lastRequestedPage = 1 - inMemoryCache.clear() - requestAndSaveData(query) - - return searchResults.asFlow() - } - - suspend fun requestMore(query: String) { - if (isRequestInProgress) return - val successful = requestAndSaveData(query) - if (successful) { - lastRequestedPage++ - } - } - - suspend fun retry(query: String) { - if (isRequestInProgress) return - requestAndSaveData(query) - } - - private suspend fun requestAndSaveData(query: String): Boolean { - isRequestInProgress = true - var successful = false - - val apiQuery = query + IN_QUALIFIER - try { - val response = service.searchRepos(apiQuery, lastRequestedPage, NETWORK_PAGE_SIZE) - Log.d("GithubRepository", "response $response") - if (response.isSuccessful) { - if (response.isSuccessful) { - val repos = response.body()?.items ?: emptyList() - inMemoryCache.addAll(repos) - val reposByName = reposByName(query) - searchResults.offer(RepoSearchResult.Success(reposByName)) - successful = true - } else { - Log.d("GithubRepository", "fail to get data") - searchResults.offer(RepoSearchResult.Error(IOException(response.message() - ?: "Unknown error"))) - } - } else { - Log.d("GithubRepository", "fail to get data") - searchResults.offer(RepoSearchResult.Error(IOException(response.message() - ?: "Unknown error"))) - } - } catch (exception: IOException) { - searchResults.offer(RepoSearchResult.Error(exception)) - } catch (exception: HttpException) { - searchResults.offer(RepoSearchResult.Error(exception)) - } - isRequestInProgress = false - return successful - } - 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. - return inMemoryCache.filter { - it.name.contains(query, true) || - (it.description != null && it.description.contains(query, true)) - }.sortedWith(compareByDescending { it.stars }.thenBy { it.name }) + // appending '%' so we can allow other characters to be before and after the query string + val dbQuery = "%${query.replace(' ', '%')}%" + val pagingSourceFactory = database.reposDao().reposByName(dbQuery).asPagingSourceFactory() + + return Pager( + config = PagingConfig(pageSize = NETWORK_PAGE_SIZE), + remoteMediator = GithubRemoteMediator( + query, + service, + database + ), + pagingSourceFactory = pagingSourceFactory + ).flow } companion object { diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt new file mode 100644 index 00000000..6dab237d --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeys.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2020 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.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "remote_keys") +data class RemoteKeys( + @PrimaryKey val repoId: Long, + val prevKey: Int?, + val nextKey: Int? +) diff --git a/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt new file mode 100644 index 00000000..a38b6f42 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RemoteKeysDao.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2020 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.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface RemoteKeysDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(remoteKey: List) + + @Query("SELECT * FROM remote_keys WHERE repoId = :repoId") + suspend fun remoteKeysRepoId(repoId: Long): RemoteKeys? + + @Query("DELETE FROM remote_keys") + suspend fun clearRemoteKeys() +} + 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 new file mode 100644 index 00000000..667fff0c --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDao.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2020 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.paging.DataSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.android.codelabs.paging.model.Repo + +@Dao +interface RepoDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(repos: List) + + @Query("SELECT * FROM repos WHERE " + + "name LIKE :queryString OR description LIKE :queryString " + + "ORDER BY stars DESC, name ASC") + fun reposByName(queryString: String): DataSource.Factory + + @Query("DELETE FROM repos") + suspend fun clearRepos() + +} \ No newline at end of file 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 new file mode 100644 index 00000000..3eb02199 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/db/RepoDatabase.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2020 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.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.example.android.codelabs.paging.model.Repo + +@Database( + entities = [Repo::class, RemoteKeys::class], + version = 1, + exportSchema = false +) +abstract class RepoDatabase : RoomDatabase() { + + abstract fun reposDao(): RepoDao + abstract fun remoteKeysDao(): RemoteKeysDao + + 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/ui/ReposAdapter.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt index d959d265..0449ca49 100644 --- a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt @@ -17,33 +17,53 @@ package com.example.android.codelabs.paging.ui import android.view.ViewGroup +import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import com.example.android.codelabs.paging.model.Repo +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.example.android.codelabs.paging.R /** * Adapter for the list of repositories. */ -class ReposAdapter : ListAdapter(REPO_COMPARATOR) { +class ReposAdapter : PagingDataAdapter(UIMODEL_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { - return RepoViewHolder.create(parent) + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return if (viewType == R.layout.repo_view_item) { + RepoViewHolder.create(parent) + } else { + SeparatorViewHolder.create(parent) + } + } + + override fun getItemViewType(position: Int): Int { + return when (getItem(position)) { + is UiModel.RepoItem -> R.layout.repo_view_item + is UiModel.SeparatorItem -> R.layout.separator_view_item + null -> throw UnsupportedOperationException("Unknown view") + } } - override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { - val repoItem = getItem(position) - if (repoItem != null) { - (holder as RepoViewHolder).bind(repoItem) + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val uiModel = getItem(position) + uiModel.let { + when (uiModel) { + is UiModel.RepoItem -> (holder as RepoViewHolder).bind(uiModel.repo) + is UiModel.SeparatorItem -> (holder as SeparatorViewHolder).bind(uiModel.description) + } } } companion object { - private val REPO_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean = - oldItem.fullName == newItem.fullName + private val UIMODEL_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: UiModel, newItem: UiModel): Boolean { + return (oldItem is UiModel.RepoItem && newItem is UiModel.RepoItem && + oldItem.repo.fullName == newItem.repo.fullName) || + (oldItem is UiModel.SeparatorItem && newItem is UiModel.SeparatorItem && + oldItem.description == newItem.description) + } - override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean = + override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean = oldItem == newItem } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateAdapter.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateAdapter.kt new file mode 100644 index 00000000..1350bd66 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateAdapter.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2020 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.ui + +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter + +class ReposLoadStateAdapter( + private val retry: () -> Unit +) : LoadStateAdapter() { + override fun onBindViewHolder(holder: ReposLoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } + + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ReposLoadStateViewHolder { + return ReposLoadStateViewHolder.create(parent, retry) + } +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateViewHolder.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateViewHolder.kt new file mode 100644 index 00000000..f567acbb --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/ReposLoadStateViewHolder.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2020 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.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.paging.LoadState +import androidx.recyclerview.widget.RecyclerView +import com.example.android.codelabs.paging.R +import com.example.android.codelabs.paging.databinding.ReposLoadStateFooterViewItemBinding + +class ReposLoadStateViewHolder( + private val binding: ReposLoadStateFooterViewItemBinding, + retry: () -> Unit +) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.retryButton.setOnClickListener { retry.invoke() } + } + + fun bind(loadState: LoadState) { + if (loadState is LoadState.Error) { + binding.errorMsg.text = loadState.error.localizedMessage + } + binding.progressBar.visibility = toVisibility(loadState is LoadState.Loading) + binding.retryButton.visibility = toVisibility(loadState !is LoadState.Loading) + binding.errorMsg.visibility = toVisibility(loadState !is LoadState.Loading) + } + + companion object { + fun create(parent: ViewGroup, retry: () -> Unit): ReposLoadStateViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.repos_load_state_footer_view_item, parent, false) + val binding = ReposLoadStateFooterViewItemBinding.bind(view) + return ReposLoadStateViewHolder(binding, retry) + } + } +} 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 7b82d6e7..6d72a2f2 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 @@ -23,15 +23,17 @@ import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.observe +import androidx.lifecycle.lifecycleScope +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadState 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.databinding.ActivitySearchRepositoriesBinding -import com.example.android.codelabs.paging.model.RepoSearchResult import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch @ExperimentalCoroutinesApi class SearchRepositoriesActivity : AppCompatActivity() { @@ -40,27 +42,37 @@ class SearchRepositoriesActivity : AppCompatActivity() { private lateinit var viewModel: SearchRepositoriesViewModel private val adapter = ReposAdapter() + private var searchJob: Job? = null + + private fun search(query: String) { + // Make sure we cancel the previous job before creating a new one + searchJob?.cancel() + searchJob = lifecycleScope.launch { + viewModel.searchRepo(query).collectLatest { + adapter.submitData(it) + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivitySearchRepositoriesBinding.inflate(layoutInflater) val view = binding.root setContentView(view) - // get the view model - viewModel = ViewModelProvider(this, Injection.provideViewModelFactory()) +// get the view model + viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(this)) .get(SearchRepositoriesViewModel::class.java) // add dividers between RecyclerView's row items val decoration = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) binding.list.addItemDecoration(decoration) - setupScrollListener() initAdapter() val query = savedInstanceState?.getString(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY - if (viewModel.repoResult.value == null) { - viewModel.searchRepo(query) - } + search(query) initSearch(query) + binding.retryButton.setOnClickListener { adapter.retry() } } override fun onSaveInstanceState(outState: Bundle) { @@ -69,17 +81,39 @@ class SearchRepositoriesActivity : AppCompatActivity() { } private fun initAdapter() { - binding.list.adapter = adapter - viewModel.repoResult.observe(this) { result -> - when (result) { - is RepoSearchResult.Success -> { - showEmptyList(result.data.isEmpty()) - adapter.submitList(result.data) + binding.list.adapter = adapter.withLoadStateHeaderAndFooter( + header = ReposLoadStateAdapter { adapter.retry() }, + footer = ReposLoadStateAdapter { adapter.retry() } + ) + adapter.addLoadStateListener { loadState -> + if (loadState.refresh !is LoadState.NotLoading) { + // We're refreshing: either loading or we had an error + // So we can hide the list + binding.list.visibility = View.GONE + binding.progressBar.visibility = toVisibility(loadState.refresh is LoadState.Loading) + binding.retryButton.visibility = toVisibility(loadState.refresh is LoadState.Error) + } else { + // We're not refreshing - we're either prepending or appending + // So we should show the list + binding.list.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.retryButton.visibility = View.GONE + // If we have an error, show a toast + val errorState = when { + loadState.append is LoadState.Error -> { + loadState.append as LoadState.Error + } + loadState.prepend is LoadState.Error -> { + loadState.prepend as LoadState.Error + } + else -> { + null + } } - is RepoSearchResult.Error -> { + errorState?.let { Toast.makeText( this, - "\uD83D\uDE28 Wooops $result.message}", + "\uD83D\uDE28 Wooops ${it.error}", Toast.LENGTH_LONG ).show() } @@ -90,6 +124,13 @@ class SearchRepositoriesActivity : AppCompatActivity() { private fun initSearch(query: String) { binding.searchRepo.setText(query) + lifecycleScope.launch { + @OptIn(ExperimentalPagingApi::class) + adapter.dataRefreshFlow.collect { + binding.list.scrollToPosition(0) + } + } + binding.searchRepo.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_GO) { updateRepoListFromInput() @@ -111,37 +152,11 @@ class SearchRepositoriesActivity : AppCompatActivity() { private fun updateRepoListFromInput() { binding.searchRepo.text.trim().let { if (it.isNotEmpty()) { - // binding.list.scrollToPosition(0) - viewModel.searchRepo(it.toString()) - //adapter.submitList(null) + search(it.toString()) } } } - private fun showEmptyList(show: Boolean) { - if (show) { - binding.emptyList.visibility = View.VISIBLE - binding.list.visibility = View.GONE - } else { - binding.emptyList.visibility = View.GONE - binding.list.visibility = View.VISIBLE - } - } - - private fun setupScrollListener() { - 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 - val lastVisibleItem = layoutManager.findLastVisibleItemPosition() - - viewModel.listScrolled(visibleItemCount, lastVisibleItem, totalItemCount) - } - }) - } - companion object { private const val LAST_SEARCH_QUERY: String = "last_search_query" private const val DEFAULT_QUERY = "Android" 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 e7748559..3a5d7546 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,13 +16,16 @@ package com.example.android.codelabs.paging.ui -import androidx.lifecycle.* +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.insertSeparators import com.example.android.codelabs.paging.data.GithubRepository -import com.example.android.codelabs.paging.model.RepoSearchResult -import kotlinx.coroutines.Dispatchers +import com.example.android.codelabs.paging.model.Repo import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map /** * ViewModel for the [SearchRepositoriesActivity] screen. @@ -30,34 +33,57 @@ import kotlinx.coroutines.launch */ @ExperimentalCoroutinesApi class SearchRepositoriesViewModel(private val repository: GithubRepository) : ViewModel() { + private var currentQueryValue: String? = null - companion object { - private const val VISIBLE_THRESHOLD = 5 - } + private var currentSearchResult: Flow>? = null - private val queryLiveData = MutableLiveData() - val repoResult: LiveData = queryLiveData.switchMap { queryString -> - liveData { - val repos = repository.getSearchResultStream(queryString).asLiveData(Dispatchers.Main) - emitSource(repos) + fun searchRepo(queryString: String): Flow> { + val lastResult = currentSearchResult + if (queryString == currentQueryValue && lastResult != null) { + return lastResult } - } - - /** - * Search a repository based on a query string. - */ - fun searchRepo(queryString: String) { - queryLiveData.postValue(queryString) - } + currentQueryValue = queryString + val newResult: Flow> = repository.getSearchResultStream(queryString) + .map { pagingData -> pagingData.map { UiModel.RepoItem(it) } } + .map { + it.insertSeparators { before, after -> + if (after == null) { + // we're at the end of the list + return@insertSeparators null + } - fun listScrolled(visibleItemCount: Int, lastVisibleItemPosition: Int, totalItemCount: Int) { - if (visibleItemCount + lastVisibleItemPosition + VISIBLE_THRESHOLD >= totalItemCount) { - val immutableQuery = queryLiveData.value - if (immutableQuery != null) { - viewModelScope.launch { - repository.requestMore(immutableQuery) + if (before == null) { + // we're at the beginning of the list + return@insertSeparators if (after.roundedStarCount >= 1) { + UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars") + } else { + UiModel.SeparatorItem("< 10.000+ stars") + } + } + // check between 2 items + if (before.roundedStarCount > after.roundedStarCount) { + if (after.roundedStarCount >= 1) { + UiModel.SeparatorItem("${after.roundedStarCount}0.000+ stars") + } else { + UiModel.SeparatorItem("< 10.000+ stars") + } + } else { + // no separator + null + } + } } - } - } + .cachedIn(viewModelScope) + currentSearchResult = newResult + return newResult } -} \ No newline at end of file + +} + +sealed class UiModel { + data class RepoItem(val repo: Repo) : UiModel() + data class SeparatorItem(val description: String) : UiModel() +} + +private val UiModel.RepoItem.roundedStarCount: Int + get() = this.repo.stars / 10_000 diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt new file mode 100644 index 00000000..812961d5 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2020 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.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.android.codelabs.paging.R + +class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) { + private val description: TextView = view.findViewById(R.id.separator_description) + + fun bind(separatorText: String) { + description.text = separatorText + } + + companion object { + fun create(parent: ViewGroup): SeparatorViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.separator_view_item, parent, false) + return SeparatorViewHolder(view) + } + } +} diff --git a/app/src/main/java/com/example/android/codelabs/paging/ui/UiUtils.kt b/app/src/main/java/com/example/android/codelabs/paging/ui/UiUtils.kt new file mode 100644 index 00000000..ddf003b1 --- /dev/null +++ b/app/src/main/java/com/example/android/codelabs/paging/ui/UiUtils.kt @@ -0,0 +1,9 @@ +package com.example.android.codelabs.paging.ui + +import android.view.View + +fun toVisibility(constraint: Boolean): Int = if (constraint) { + View.VISIBLE +} else { + View.GONE +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_search_repositories.xml b/app/src/main/res/layout/activity_search_repositories.xml index 738040a3..5695d150 100644 --- a/app/src/main/res/layout/activity_search_repositories.xml +++ b/app/src/main/res/layout/activity_search_repositories.xml @@ -59,16 +59,25 @@ app:layout_constraintTop_toBottomOf="@+id/input_layout" tools:ignore="UnusedAttribute"/> - + + +