diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/Paging-3.iml b/.idea/Paging-3.iml new file mode 100644 index 00000000..697945a8 --- /dev/null +++ b/.idea/Paging-3.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..237afc07 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..8146eaef --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/advanced/end/app/build.gradle b/advanced/end/app/build.gradle index 3aaf3f2d..eaeb6627 100644 --- a/advanced/end/app/build.gradle +++ b/advanced/end/app/build.gradle @@ -51,6 +51,7 @@ android { } dependencies { + implementation freeCompilerArgs += ["-Xopt-in=kotlin.RequiresOptIn"] implementation fileTree(dir: 'libs') implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines" diff --git a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt new file mode 100644 index 00000000..1f00db26 --- /dev/null +++ b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/data/GithubPagingSource.kt @@ -0,0 +1,44 @@ +// GitHub page API is 1 based: https://developer.github.com/v3/#pagination +private const val GITHUB_STARTING_PAGE_INDEX = 1 + +class GithubPagingSource( + private val service: GithubService, + private val query: String +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + val position = params.key ?: GITHUB_STARTING_PAGE_INDEX + val apiQuery = query + IN_QUALIFIER + return try { + val response = service.searchRepos(apiQuery, position, params.loadSize) + val repos = response.items + val nextKey = if (repos.isEmpty()) { + null + } else { + // initial load size = 3 * NETWORK_PAGE_SIZE + // ensure we're not requesting duplicating items, at the 2nd request + position + (params.loadSize / NETWORK_PAGE_SIZE) + } + LoadResult.Page( + data = repos, + prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1, + nextKey = nextKey + ) + } catch (exception: IOException) { + return LoadResult.Error(exception) + } catch (exception: HttpException) { + return LoadResult.Error(exception) + } + } + // The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load + override fun getRefreshKey(state: PagingState): Int? { + // We need to get the previous key (or next key if previous is null) of the page + // that was closest to the most recently accessed index. + // Anchor position is the most recently accessed index + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } + +} \ No newline at end of file diff --git a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt index 855e8e9f..382d6811 100644 --- a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt +++ b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/data/GithubRepository.kt @@ -26,38 +26,34 @@ import com.example.android.codelabs.paging.db.RepoDatabase import com.example.android.codelabs.paging.model.Repo import kotlinx.coroutines.flow.Flow -/** - * Repository class that works with local and remote data sources. +/** Paging 3 now : ) + * 1. Handles in-memory cache. + 2. Requests data when the user is close to the end of the list. */ -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. - */ - fun getSearchResultStream(query: String): Flow> { - Log.d("GithubRepository", "New query: $query") +class GithubRepository(private val service: GithubService, + private val database: RepoDatabase +) { - // 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) } + // 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)} + fun getSearchResultStream(query: String): Flow> { @OptIn(ExperimentalPagingApi::class) return Pager( - config = PagingConfig(pageSize = NETWORK_PAGE_SIZE, enablePlaceholders = false), - remoteMediator = GithubRemoteMediator( - query, - service, - database + config = PagingConfig( + pageSize = NETWORK_PAGE_SIZE, + maxSize = NETWORK_MAX_SIZE, + enablePlaceholders = false ), + remoteMediator = GithubRemoteMediator(service, query, database) } pagingSourceFactory = pagingSourceFactory ).flow } companion object { - const val NETWORK_PAGE_SIZE = 30 + const val NETWORK_PAGE_SIZE = 50 + const val NETWORK_MAX_SIZE = 150 } } diff --git a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt index 0449ca49..5e3a3f72 100644 --- a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt +++ b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/ReposAdapter.kt @@ -63,7 +63,7 @@ class ReposAdapter : PagingDataAdapter(UIMODEL_COMPARATOR) } override fun areContentsTheSame(oldItem: UiModel, newItem: UiModel): Boolean = - oldItem == newItem + oldItem == newItem } } } \ No newline at end of file diff --git a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt index 23316fa7..65fc8608 100644 --- a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt +++ b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesActivity.kt @@ -48,12 +48,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { setContentView(view) // get the view model - val viewModel = ViewModelProvider( - this, Injection.provideViewModelFactory( - context = this, - owner = this - ) - ) + val viewModel = ViewModelProvider(this, Injection.provideViewModelFactory(owner = this, context = this)) .get(SearchRepositoriesViewModel::class.java) // add dividers between RecyclerView's row items @@ -74,21 +69,20 @@ class SearchRepositoriesActivity : AppCompatActivity() { */ private fun ActivitySearchRepositoriesBinding.bindState( uiState: StateFlow, - pagingData: Flow>, + pagingData: Flow>, uiActions: (UiAction) -> Unit ) { val repoAdapter = ReposAdapter() - val header = ReposLoadStateAdapter { repoAdapter.retry() } list.adapter = repoAdapter.withLoadStateHeaderAndFooter( - header = header, + header = ReposLoadStateAdapter { repoAdapter.retry() }, footer = ReposLoadStateAdapter { repoAdapter.retry() } ) + bindSearch( uiState = uiState, onQueryChanged = uiActions ) bindList( - header = header, repoAdapter = repoAdapter, uiState = uiState, pagingData = pagingData, @@ -135,21 +129,21 @@ class SearchRepositoriesActivity : AppCompatActivity() { } private fun ActivitySearchRepositoriesBinding.bindList( - header: ReposLoadStateAdapter, repoAdapter: ReposAdapter, uiState: StateFlow, - pagingData: Flow>, + pagingData: Flow>, onScrollChanged: (UiAction.Scroll) -> Unit ) { - retryButton.setOnClickListener { repoAdapter.retry() } list.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (dy != 0) onScrollChanged(UiAction.Scroll(currentQuery = uiState.value.query)) } }) val notLoading = repoAdapter.loadStateFlow - .asRemotePresentationState() - .map { it == RemotePresentationState.PRESENTED } + // Only emit when REFRESH LoadState for the paging source changes. + .distinctUntilChangedBy { it.source.refresh } + // Only react to cases where REFRESH completes i.e., NotLoading. + .map { it.source.refresh is LoadState.NotLoading } val hasNotScrolledForCurrentSearch = uiState .map { it.hasNotScrolledForCurrentSearch } @@ -162,6 +156,19 @@ class SearchRepositoriesActivity : AppCompatActivity() { ) .distinctUntilChanged() + + // Collecting from loadStateFlow directly. + lifecycleScope.launch { + repoAdapter.loadStateFlow.collect { loadState -> + val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0 + // show empty list + emptyList.isVisible = isListEmpty + // Only show the list if refresh succeeds. + list.isVisible = !isListEmpty + } + } + + /* lifecycleScope.launch { pagingData.collectLatest(repoAdapter::submitData) } @@ -171,38 +178,7 @@ class SearchRepositoriesActivity : AppCompatActivity() { if (shouldScroll) list.scrollToPosition(0) } } + */ - lifecycleScope.launch { - repoAdapter.loadStateFlow.collect { loadState -> - // Show a retry header if there was an error refreshing, and items were previously - // cached OR default to the default prepend state - header.loadState = loadState.mediator - ?.refresh - ?.takeIf { it is LoadState.Error && repoAdapter.itemCount > 0 } - ?: loadState.prepend - - val isListEmpty = loadState.refresh is LoadState.NotLoading && repoAdapter.itemCount == 0 - // show empty list - emptyList.isVisible = isListEmpty - // Only show the list if refresh succeeds, either from the the local db or the remote. - list.isVisible = loadState.source.refresh is LoadState.NotLoading || loadState.mediator?.refresh is LoadState.NotLoading - // Show loading spinner during initial load or refresh. - progressBar.isVisible = loadState.mediator?.refresh is LoadState.Loading - // Show the retry state if initial load or refresh fails. - retryButton.isVisible = loadState.mediator?.refresh is LoadState.Error && repoAdapter.itemCount == 0 - // Toast on any error, regardless of whether it came from RemoteMediator or PagingSource - val errorState = loadState.source.append as? LoadState.Error - ?: loadState.source.prepend as? LoadState.Error - ?: loadState.append as? LoadState.Error - ?: loadState.prepend as? LoadState.Error - errorState?.let { - Toast.makeText( - this@SearchRepositoriesActivity, - "\uD83D\uDE28 Wooops ${it.error}", - Toast.LENGTH_LONG - ).show() - } - } - } } } diff --git a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt index f4ccea8d..42319ea2 100644 --- a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt +++ b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SearchRepositoriesViewModel.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.launch */ class SearchRepositoriesViewModel( private val repository: GithubRepository, - private val savedStateHandle: SavedStateHandle + private val savedStateHandle: SavedStateHandle // SavedStateRegistryOwner ) : ViewModel() { /** @@ -53,7 +53,7 @@ class SearchRepositoriesViewModel( */ val state: StateFlow - val pagingDataFlow: Flow> + val pagingDataFlow: Flow> /** * Processor of side effects from the UI which in turn feedback into [state] @@ -61,6 +61,8 @@ class SearchRepositoriesViewModel( val accept: (UiAction) -> Unit init { + + // UiAction Stream val initialQuery: String = savedStateHandle.get(LAST_SEARCH_QUERY) ?: DEFAULT_QUERY val lastQueryScrolled: String = savedStateHandle.get(LAST_QUERY_SCROLLED) ?: DEFAULT_QUERY val actionStateFlow = MutableSharedFlow() @@ -80,6 +82,7 @@ class SearchRepositoriesViewModel( ) .onStart { emit(UiAction.Scroll(currentQuery = lastQueryScrolled)) } + // flows for both PagingData and UiState pagingDataFlow = searches .flatMapLatest { searchRepo(queryString = it.query) } .cachedIn(viewModelScope) @@ -143,14 +146,16 @@ class SearchRepositoriesViewModel( } sealed class UiAction { - data class Search(val query: String) : UiAction() - data class Scroll(val currentQuery: String) : UiAction() + data class Search(val query: String) : UiAction() // query + data class Scroll(//val currentQuery: String, + val visibleItemCount: Int, + val lastVisibleItemPosition: Int, + val totalItemCount: Int) : UiAction() // scrolling down the screen to load alot of data } data class UiState( - val query: String = DEFAULT_QUERY, - val lastQueryScrolled: String = DEFAULT_QUERY, - val hasNotScrolledForCurrentSearch: Boolean = false + val query: String, + val searchResult: RepoSearchResult ) sealed class UiModel { diff --git a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt index 812961d5..996097c1 100644 --- a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt +++ b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/ui/SeparatorViewHolder.kt @@ -33,7 +33,7 @@ class SeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) { companion object { fun create(parent: ViewGroup): SeparatorViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(R.layout.separator_view_item, parent, false) + .inflate(R.layout.separator_view_item, parent, false) return SeparatorViewHolder(view) } } diff --git a/advanced/end/app/src/main/java/com/example/android/codelabs/paging/util/RemotePresentationState.kt b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/util/RemotePresentationState.kt new file mode 100644 index 00000000..df1b55bb --- /dev/null +++ b/advanced/end/app/src/main/java/com/example/android/codelabs/paging/util/RemotePresentationState.kt @@ -0,0 +1,3 @@ +enum class RemotePresentationState { + INITIAL, REMOTE_LOADING, SOURCE_LOADING, PRESENTED +} \ No newline at end of file diff --git a/advanced/start/app/src/main/java/com/example/android/codelabs/paging/Injection.kt b/advanced/start/app/src/main/java/com/example/android/codelabs/paging/Injection.kt index 2eba2eb3..f163e868 100644 --- a/advanced/start/app/src/main/java/com/example/android/codelabs/paging/Injection.kt +++ b/advanced/start/app/src/main/java/com/example/android/codelabs/paging/Injection.kt @@ -23,13 +23,13 @@ import com.example.android.codelabs.paging.data.GithubRepository import com.example.android.codelabs.paging.ui.ViewModelFactory /** - * Class that handles object creation. + * Class that handles creation. * Like this, objects can be passed as parameters in the constructors and then replaced for * testing, where needed. */ object Injection { - /** + /**object * Creates an instance of [GithubRepository] based on the [GithubService] and a * [GithubLocalCache] */ diff --git a/basic/end/settings.gradle b/basic/end/settings.gradle index cf84bb15..cd5b7bc6 100644 --- a/basic/end/settings.gradle +++ b/basic/end/settings.gradle @@ -3,6 +3,14 @@ // https://docs.gradle.org/current/userguide/platforms.html enableFeaturePreview("VERSION_CATALOGS") +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories {