From d3930d7587305c6f4c9c24d6c0bef331dc60ed67 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 4 Jun 2024 19:48:50 +0300 Subject: [PATCH] Adding paging via Jetpack Paging --- .../newssearchapp/AppModule.kt | 25 +++-- .../news/data/AllArticlesPagingSource.kt | 53 +++++++++ .../news/data/AllArticlesRemoteMediator.kt | 80 ++++++++++++++ database/build.gradle.kts | 3 + .../news/database/dao/ArticleDao.kt | 15 ++- .../news/database/models/ArticleDBO.kt | 2 +- features/news-main/domain/build.gradle.kts | 4 +- .../androidbroadcast/news/main/ArticleUI.kt | 2 +- .../news/main/GetAllArticlesUseCase.kt | 18 ---- .../dev/androidbroadcast/news/main/Mappers.kt | 10 -- .../news/main/NewsMainViewModel.kt | 40 ++++++- .../dev/androidbroadcast/news/main/State.kt | 14 --- features/news-main/ui/build.gradle.kts | 1 + .../news/main/ArticleListContent.kt | 94 +++++++--------- .../news/main/NewsMainFeatureUI.kt | 54 ++++------ gradle/libs.versions.toml | 6 ++ news-data/build.gradle.kts | 1 + .../news/data/ArticlesRepository.kt | 101 ++++-------------- .../dev/androidbroadcast/news/data/Mappers.kt | 3 +- .../news/data/MergeStrategy.kt | 97 ----------------- .../news/data/model/Article.kt | 4 +- .../dev/androidbroadcast/newsapi/NewsApi.kt | 6 +- 22 files changed, 304 insertions(+), 329 deletions(-) create mode 100644 core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesPagingSource.kt create mode 100644 core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesRemoteMediator.kt delete mode 100644 features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/GetAllArticlesUseCase.kt delete mode 100644 features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/State.kt delete mode 100644 news-data/src/main/java/dev/androidbroadcast/news/data/MergeStrategy.kt diff --git a/app/src/main/java/dev/androidbroadcast/newssearchapp/AppModule.kt b/app/src/main/java/dev/androidbroadcast/newssearchapp/AppModule.kt index af9277f..83f9a05 100644 --- a/app/src/main/java/dev/androidbroadcast/newssearchapp/AppModule.kt +++ b/app/src/main/java/dev/androidbroadcast/newssearchapp/AppModule.kt @@ -10,13 +10,14 @@ import dev.androidbroadcast.common.AndroidLogcatLogger import dev.androidbroadcast.common.AppDispatchers import dev.androidbroadcast.common.Logger import dev.androidbroadcast.news.database.NewsDatabase +import dev.androidbroadcast.news.database.dao.ArticleDao import dev.androidbroadcast.newsapi.NewsApi import okhttp3.OkHttpClient import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object AppModule { +internal object AppModule { @Provides @Singleton fun provideNewsApi(okHttpClient: OkHttpClient?): NewsApi { @@ -27,6 +28,18 @@ object AppModule { ) } + @Provides + @Singleton + fun provideAppCoroutineDispatchers(): AppDispatchers = AppDispatchers() + + @Provides + fun provideLogger(): Logger = AndroidLogcatLogger() +} + +@Module +@InstallIn(SingletonComponent::class) +internal object DatabaseDao { + @Provides @Singleton fun provideNewsDatabase( @@ -36,9 +49,9 @@ object AppModule { } @Provides - @Singleton - fun provideAppCoroutineDispatchers(): AppDispatchers = AppDispatchers() - - @Provides - fun provideLogger(): Logger = AndroidLogcatLogger() + fun provideArticleDao( + database: NewsDatabase + ): ArticleDao { + return database.articlesDao + } } diff --git a/core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesPagingSource.kt b/core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesPagingSource.kt new file mode 100644 index 0000000..99978eb --- /dev/null +++ b/core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesPagingSource.kt @@ -0,0 +1,53 @@ +package dev.androidbroadcast.news.data + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import dev.androidbroadcast.news.data.model.Article +import dev.androidbroadcast.newsapi.NewsApi +import javax.inject.Inject +import javax.inject.Provider + + +public class AllArticlesPagingSource( + private val query: String, + private val newsApi: NewsApi, +) : PagingSource() { + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) + } + } + + override suspend fun load( + params: LoadParams, + ): LoadResult { + val page = params.key ?: 1 + val result = newsApi.everything(query, page = page, pageSize = params.loadSize) + .map { articles -> articles.articles.map { it.toArticle(id = counter++) } } + when { + result.isSuccess -> { + val articles: List
= result.getOrThrow() + val prev: Int? = params.key?.let { it - 1 }?.takeIf { it > 0 } + val next: Int? = if (articles.isEmpty()) null else page + 1 + return LoadResult.Page(articles, prevKey = prev, nextKey = next) + } + + result.isFailure -> return LoadResult.Error(result.exceptionOrNull() ?: UnknownError()) + else -> error("Impossible branch") + } + } + + public class Factory @Inject constructor(private val newsApi: Provider) { + + public fun newInstance(query: String): PagingSource { + return AllArticlesPagingSource(query, newsApi.get()) + } + } + + private companion object { + + var counter = 1 + } +} diff --git a/core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesRemoteMediator.kt b/core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesRemoteMediator.kt new file mode 100644 index 0000000..710a253 --- /dev/null +++ b/core/data/src/main/java/dev/androidbroadcast/news/data/AllArticlesRemoteMediator.kt @@ -0,0 +1,80 @@ +package dev.androidbroadcast.news.data + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import dev.androidbroadcast.news.database.dao.ArticleDao +import dev.androidbroadcast.news.database.models.ArticleDBO +import dev.androidbroadcast.newsapi.NewsApi +import javax.inject.Inject +import javax.inject.Provider + +@ExperimentalPagingApi +public class AllArticlesRemoteMediator internal constructor( + private val query: String, + private val articleDao: ArticleDao, + private val networkService: NewsApi, +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + val pageSize: Int = state.config.pageSize.coerceAtMost(NewsApi.MAX_PAGE_SIZE) + + val page: Int = getPage(loadType, state) + ?: return MediatorResult.Success(endOfPaginationReached = false) + + val networkResult = networkService.everything(query, page = page, pageSize = pageSize) + if (networkResult.isSuccess) { + val totalResults = networkResult.getOrThrow().totalResults + println(totalResults) + val articlesDbo = networkResult.getOrThrow().articles.map { it.toArticleDbo() } + if (loadType == LoadType.REFRESH) { + articleDao.cleanAndInsert(articlesDbo) + } else { + articleDao.insert(articlesDbo) + } + + return MediatorResult.Success( + endOfPaginationReached = articlesDbo.size < pageSize + ) + } + + return MediatorResult.Error(networkResult.exceptionOrNull() ?: UnknownError()) + } + + private fun getPage( + loadType: LoadType, + state: PagingState + ): Int? = when (loadType) { + LoadType.REFRESH -> + state.anchorPosition?.let { state.closestPageToPosition(it) }?.prevKey ?: 1 + + LoadType.PREPEND -> null + + LoadType.APPEND -> { + val lastPage = state.pages.lastOrNull() + if (lastPage == null) { + 1 + } else { + state.pages.size + 1 + } + } + } + + public class Factory @Inject constructor( + private val articleDao: Provider, + private val networkService: Provider, + ) { + + public fun create(query: String): AllArticlesRemoteMediator { + return AllArticlesRemoteMediator( + query = query, + articleDao = articleDao.get(), + networkService = networkService.get() + ) + } + } +} diff --git a/database/build.gradle.kts b/database/build.gradle.kts index b4296b4..86c05e2 100644 --- a/database/build.gradle.kts +++ b/database/build.gradle.kts @@ -42,4 +42,7 @@ dependencies { implementation(libs.androidx.core.ktx) ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.ktx) + // Делаю API зависимость чтобы другие модули могли работать с получаемымы результатом из DAO + api(libs.androidx.room.paging) + implementation(libs.androidx.paging.common) } diff --git a/database/src/main/java/dev/androidbroadcast/news/database/dao/ArticleDao.kt b/database/src/main/java/dev/androidbroadcast/news/database/dao/ArticleDao.kt index eb08b76..cfee423 100644 --- a/database/src/main/java/dev/androidbroadcast/news/database/dao/ArticleDao.kt +++ b/database/src/main/java/dev/androidbroadcast/news/database/dao/ArticleDao.kt @@ -1,9 +1,12 @@ package dev.androidbroadcast.news.database.dao +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import dev.androidbroadcast.news.database.models.ArticleDBO import kotlinx.coroutines.flow.Flow @@ -12,10 +15,13 @@ interface ArticleDao { @Query("SELECT * FROM articles") suspend fun getAll(): List + @Query("SELECT * FROM articles") + fun pagingAll(): PagingSource + @Query("SELECT * FROM articles") fun observeAll(): Flow> - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(articles: List) @Delete @@ -23,4 +29,11 @@ interface ArticleDao { @Query("DELETE FROM articles") suspend fun clean() + + @Transaction + suspend fun cleanAndInsert(articles: List) { + // Anything inside this method runs in a single transaction. + clean() + insert(articles) + } } diff --git a/database/src/main/java/dev/androidbroadcast/news/database/models/ArticleDBO.kt b/database/src/main/java/dev/androidbroadcast/news/database/models/ArticleDBO.kt index 09e1400..f914993 100644 --- a/database/src/main/java/dev/androidbroadcast/news/database/models/ArticleDBO.kt +++ b/database/src/main/java/dev/androidbroadcast/news/database/models/ArticleDBO.kt @@ -16,7 +16,7 @@ data class ArticleDBO( @ColumnInfo("urlToImage") val urlToImage: String?, @ColumnInfo("publishedAt") val publishedAt: Date, @ColumnInfo("content") val content: String, - @PrimaryKey(autoGenerate = true) val id: Long = 0 + @PrimaryKey(autoGenerate = true) val id: Int = 0, ) data class Source( diff --git a/features/news-main/domain/build.gradle.kts b/features/news-main/domain/build.gradle.kts index 99e673b..d1dcdfd 100644 --- a/features/news-main/domain/build.gradle.kts +++ b/features/news-main/domain/build.gradle.kts @@ -48,8 +48,10 @@ dependencies { compileOnly(libs.androidx.compose.runtime) api(libs.kotlinx.immutable) - api(project(":news-data")) + api(projects.newsData) + api(projects.database) implementation(libs.dagger.hilt.android) kapt(libs.dagger.hilt.compiler) + implementation(libs.androidx.paging.common) } diff --git a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/ArticleUI.kt b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/ArticleUI.kt index 69fdeec..df39214 100644 --- a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/ArticleUI.kt +++ b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/ArticleUI.kt @@ -1,7 +1,7 @@ package dev.androidbroadcast.news.main public data class ArticleUI( - val id: Long, + val id: Int, val title: String, val description: String, val imageUrl: String?, diff --git a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/GetAllArticlesUseCase.kt b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/GetAllArticlesUseCase.kt deleted file mode 100644 index e316eb8..0000000 --- a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/GetAllArticlesUseCase.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.androidbroadcast.news.main - -import dev.androidbroadcast.news.data.ArticlesRepository -import dev.androidbroadcast.news.data.RequestResult -import dev.androidbroadcast.news.data.map -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import javax.inject.Inject - -internal class GetAllArticlesUseCase @Inject constructor( - private val repository: ArticlesRepository -) { - operator fun invoke(query: String): Flow>> { - return repository.getAll(query).map { requestResult -> - requestResult.map { articles -> articles.map { it.toUiArticle() } } - } - } -} diff --git a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/Mappers.kt b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/Mappers.kt index 8a63b6e..6cf2c30 100644 --- a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/Mappers.kt +++ b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/Mappers.kt @@ -1,16 +1,6 @@ package dev.androidbroadcast.news.main -import dev.androidbroadcast.news.data.RequestResult import dev.androidbroadcast.news.data.model.Article -import kotlinx.collections.immutable.toImmutableList - -internal fun RequestResult>.toState(): State { - return when (this) { - is RequestResult.Error -> State.Error(data?.toImmutableList()) - is RequestResult.InProgress -> State.Loading(data?.toImmutableList()) - is RequestResult.Success -> State.Success(data.toImmutableList()) - } -} internal fun Article.toUiArticle(): ArticleUI { return ArticleUI( diff --git a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/NewsMainViewModel.kt b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/NewsMainViewModel.kt index 02ed24d..470016b 100644 --- a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/NewsMainViewModel.kt +++ b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/NewsMainViewModel.kt @@ -2,9 +2,18 @@ package dev.androidbroadcast.news.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map import dagger.hilt.android.lifecycle.HiltViewModel +import dev.androidbroadcast.news.data.ArticlesRepository +import dev.androidbroadcast.news.data.model.Article +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @@ -12,11 +21,32 @@ import javax.inject.Provider @HiltViewModel public class NewsMainViewModel @Inject internal constructor( - getAllArticlesUseCase: Provider + articlesRepository: ArticlesRepository ) : ViewModel() { - public val state: StateFlow = - getAllArticlesUseCase.get().invoke(query = "android") - .map { it.toState() } - .stateIn(viewModelScope, SharingStarted.Lazily, State.None) + private val _query: MutableStateFlow = MutableStateFlow("android") + + public val query: StateFlow + get() = _query.asStateFlow() + + private val pagingConfig = PagingConfig( + initialLoadSize = 10, + pageSize = 10, + maxSize = 100, + enablePlaceholders = false, + ) + + public val state: StateFlow> = query + .map { query -> + articlesRepository.allArticles( + query, + config = pagingConfig + ) + } + .flatMapConcat { pagingDataFlow -> + pagingDataFlow.map { pagingData -> + pagingData.map(Article::toUiArticle) + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, PagingData.empty()) } diff --git a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/State.kt b/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/State.kt deleted file mode 100644 index bf67b70..0000000 --- a/features/news-main/domain/src/main/java/dev/androidbroadcast/news/main/State.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.androidbroadcast.news.main - -import kotlinx.collections.immutable.ImmutableList - -public sealed class State(public open val articles: ImmutableList?) { - - public data object None : State(articles = null) - - public class Loading(articles: ImmutableList? = null) : State(articles) - - public class Error(articles: ImmutableList? = null) : State(articles) - - public class Success(override val articles: ImmutableList) : State(articles) -} diff --git a/features/news-main/ui/build.gradle.kts b/features/news-main/ui/build.gradle.kts index 7206d7e..88d2e4b 100644 --- a/features/news-main/ui/build.gradle.kts +++ b/features/news-main/ui/build.gradle.kts @@ -64,4 +64,5 @@ dependencies { implementation(libs.coil.compose) implementation(projects.newsCommon) + implementation(libs.androidx.paging.compose) } diff --git a/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/ArticleListContent.kt b/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/ArticleListContent.kt index 04dedcd..2508b50 100644 --- a/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/ArticleListContent.kt +++ b/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/ArticleListContent.kt @@ -1,16 +1,19 @@ package dev.androidbroadcast.news.main +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -21,37 +24,56 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import dev.androidbroadcast.news.NewsTheme @Composable internal fun ArticleList( - articleState: State.Success + items: LazyPagingItems ) { - ArticleList(articles = articleState.articles) -} + val lazyListState = rememberLazyListState() + Box(Modifier.fillMaxSize()) { + LazyColumn(state = lazyListState) { + if (items.loadState.prepend is LoadState.Loading) { + item { ProgressIndicator() } + } -@Preview -@Composable -internal fun ArticleList( - @PreviewParameter(ArticlesPreviewProvider::class, limit = 1) articles: List -) { - LazyColumn { - items(articles) { article -> - key(article.id) { - Article(article) + items( + count = items.itemCount, + key = items.itemKey { it.id } + ) { + val item = items[it] + if (item != null) Article(item) + } + + if (items.loadState.append is LoadState.Loading) { + item { ProgressIndicator() } } } + + if (items.loadState.append is LoadState.Error) { + ErrorMessage() + } } } -@Preview + @Composable internal fun Article( - @PreviewParameter(ArticlePreviewProvider::class, limit = 1) article: ArticleUI + article: ArticleUI, + modifier: Modifier = Modifier, ) { - Row(Modifier.padding(bottom = 4.dp)) { + Row( + Modifier + .padding(bottom = 4.dp) + .height(150.dp) + .then(modifier) + ) { article.imageUrl?.let { imageUrl -> var isImageVisible by remember { mutableStateOf(true) } if (isImageVisible) { @@ -84,41 +106,3 @@ internal fun Article( } } } - -private class ArticlesPreviewProvider : PreviewParameterProvider> { - private val articleProvider = ArticlePreviewProvider() - - override val values = - sequenceOf( - articleProvider.values - .toList() - ) -} - -@Suppress("MagicNumber") -private class ArticlePreviewProvider : PreviewParameterProvider { - override val values = - sequenceOf( - ArticleUI( - 1, - "Android Studio Iguana is Stable!", - "New stable version on Android IDE has been release", - imageUrl = null, - url = "" - ), - ArticleUI( - 2, - "Gemini 1.5 Release", - "Upgraded version of Google AI is available", - imageUrl = null, - url = "" - ), - ArticleUI( - 3, - "Shape animations (10 min)", - "How to use shape transform animations in Compose", - imageUrl = null, - url = "" - ) - ) -} diff --git a/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/NewsMainFeatureUI.kt b/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/NewsMainFeatureUI.kt index 155618d..38ff3d8 100644 --- a/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/NewsMainFeatureUI.kt +++ b/features/news-main/ui/src/main/java/dev/androidbroadcast/news/main/NewsMainFeatureUI.kt @@ -1,6 +1,7 @@ package dev.androidbroadcast.news.main import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -8,12 +9,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems import dev.androidbroadcast.news.NewsTheme @Composable @@ -26,28 +29,30 @@ internal fun NewsMainScreen( viewModel: NewsMainViewModel, modifier: Modifier = Modifier, ) { - val state by viewModel.state.collectAsState() - val currentState = state - NewsMainContent(currentState, modifier) + val articlesItems: LazyPagingItems = viewModel.state.collectAsLazyPagingItems() + NewsMainContent(articlesItems, modifier) } @Composable private fun NewsMainContent( - currentState: State, + articlesItems: LazyPagingItems, modifier: Modifier = Modifier, ) { - Column(modifier) { - when (currentState) { - is State.None -> Unit - is State.Error -> ErrorMessage(currentState) - is State.Loading -> ProgressIndicator(currentState) - is State.Success -> ArticleList(currentState) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + when (articlesItems.loadState.refresh) { + is LoadState.Loading -> ProgressIndicator() + is LoadState.Error -> ErrorMessage() + is LoadState.NotLoading -> ArticleList(articlesItems) } } } @Composable -private fun ErrorMessage(state: State.Error) { +internal fun ErrorMessage() { Column { Box( Modifier @@ -58,29 +63,10 @@ private fun ErrorMessage(state: State.Error) { ) { Text(text = "Error during update", color = NewsTheme.colorScheme.onError) } - - val articles = state.articles - if (articles != null) { - ArticleList(articles = articles) - } } } @Composable -private fun ProgressIndicator(state: State.Loading) { - Column { - Box( - Modifier - .fillMaxWidth() - .padding(8.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - - val articles = state.articles - if (articles != null) { - ArticleList(articles = articles) - } - } +internal fun ProgressIndicator() { + CircularProgressIndicator(Modifier.padding(8.dp)) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 88e269e..70368c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,11 +32,13 @@ androidSdk-min = "24" androidSdk-compile = "34" kotlinx-immutable = "0.3.7" compose-rules = "0.4.3" +androidx-paging = "3.3.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" } junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -58,6 +60,10 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui- androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "androidx-compose-runtime" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" } +androidx-paging-common = { module = "androidx.paging:paging-common", version.ref = "androidx-paging" } + androidx-material3 = { group = "androidx.compose.material3", name = "material3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-adapters-result = { module = "com.github.skydoves:retrofit-adapters-result", version.ref = "retrofitAdaptersResult" } diff --git a/news-data/build.gradle.kts b/news-data/build.gradle.kts index 38471de..f7398ae 100644 --- a/news-data/build.gradle.kts +++ b/news-data/build.gradle.kts @@ -47,4 +47,5 @@ dependencies { implementation(project(":news-common")) implementation(libs.javax.inject) + implementation(libs.androidx.paging.common) } diff --git a/news-data/src/main/java/dev/androidbroadcast/news/data/ArticlesRepository.kt b/news-data/src/main/java/dev/androidbroadcast/news/data/ArticlesRepository.kt index 70eb388..dc9db1a 100644 --- a/news-data/src/main/java/dev/androidbroadcast/news/data/ArticlesRepository.kt +++ b/news-data/src/main/java/dev/androidbroadcast/news/data/ArticlesRepository.kt @@ -1,97 +1,34 @@ +@file:OptIn(ExperimentalPagingApi::class) + package dev.androidbroadcast.news.data -import dev.androidbroadcast.common.Logger +import androidx.paging.ExperimentalPagingApi +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map import dev.androidbroadcast.news.data.model.Article import dev.androidbroadcast.news.database.NewsDatabase import dev.androidbroadcast.news.database.models.ArticleDBO -import dev.androidbroadcast.newsapi.NewsApi -import dev.androidbroadcast.newsapi.models.ArticleDTO -import dev.androidbroadcast.newsapi.models.ResponseDTO import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onEach import javax.inject.Inject public class ArticlesRepository @Inject constructor( private val database: NewsDatabase, - private val api: NewsApi, - private val logger: Logger + private val remoteMediator: AllArticlesRemoteMediator.Factory, ) { - /** - * Получение актуальных новостей с отслеживанием состояния запроса ("Обновляется", "Успшено", "Ошибка") - */ - public fun getAll( - query: String, - mergeStrategy: MergeStrategy>> = RequestResponseMergeStrategy() - ): Flow>> { - val cachedAllArticles: Flow>> = gelAllFromDatabase() - val remoteArticles: Flow>> = getAllFromServer(query) - - return cachedAllArticles.combine(remoteArticles, mergeStrategy::merge) - .flatMapLatest { result -> - if (result is RequestResult.Success) { - database.articlesDao.observeAll() - .map { dbos -> dbos.map { it.toArticle() } } - .map { RequestResult.Success(it) } - } else { - flowOf(result) - } - } - } - - private fun getAllFromServer(query: String): Flow>> { - val apiRequest = - flow { emit(api.everything(query = query)) } - .onEach { result -> - // Если запрос прошел успешно, сохраняем данные в локальный кэш (БД) - if (result.isSuccess) saveArticlesToCache(result.getOrThrow().articles) - } - .onEach { result -> - if (result.isFailure) { - logger.e( - LOG_TAG, - "Error getting data from server. Cause = ${result.exceptionOrNull()}" - ) - } - } - .map { it.toRequestResult() } - - val start = flowOf>>(RequestResult.InProgress()) - return merge(apiRequest, start) - .map { result: RequestResult> -> - result.map { response -> response.articles.map { it.toArticle() } } - } - } - private suspend fun saveArticlesToCache(data: List) { - val dbos = data.map { articleDto -> articleDto.toArticleDbo() } - database.articlesDao.insert(dbos) - } - - private fun gelAllFromDatabase(): Flow>> { - val dbRequest = - database.articlesDao::getAll.asFlow() - .map, RequestResult>> { RequestResult.Success(it) } - .catch { - logger.e(LOG_TAG, "Error getting from database. Cause = $it") - emit(RequestResult.Error(error = it)) - } - - val start = flowOf>>(RequestResult.InProgress()) - - return merge(start, dbRequest).map { result -> - result.map { dbos -> dbos.map { it.toArticle() } } - } - } - - private companion object { - const val LOG_TAG = "ArticlesRepository" + public fun allArticles( + query: String, + config: PagingConfig, + ): Flow> { + val pager = Pager( + config = config, + remoteMediator = remoteMediator.create(query), + pagingSourceFactory = { database.articlesDao.pagingAll() } + ) + return pager.flow + .map { pagingData -> pagingData.map { it.toArticle() } } } } diff --git a/news-data/src/main/java/dev/androidbroadcast/news/data/Mappers.kt b/news-data/src/main/java/dev/androidbroadcast/news/data/Mappers.kt index 688bf37..1de5732 100644 --- a/news-data/src/main/java/dev/androidbroadcast/news/data/Mappers.kt +++ b/news-data/src/main/java/dev/androidbroadcast/news/data/Mappers.kt @@ -33,8 +33,9 @@ internal fun SourceDTO.toSourceDbo(): SourceDBO { return SourceDBO(id = id ?: name, name = name) } -internal fun ArticleDTO.toArticle(): Article { +internal fun ArticleDTO.toArticle(id: Int = 0): Article { return Article( + cacheId = id, source = source.toSource(), author = author, title = title, diff --git a/news-data/src/main/java/dev/androidbroadcast/news/data/MergeStrategy.kt b/news-data/src/main/java/dev/androidbroadcast/news/data/MergeStrategy.kt deleted file mode 100644 index f0c897e..0000000 --- a/news-data/src/main/java/dev/androidbroadcast/news/data/MergeStrategy.kt +++ /dev/null @@ -1,97 +0,0 @@ -@file:Suppress("UNUSED_PARAMETER") - -package dev.androidbroadcast.news.data - -import dev.androidbroadcast.news.data.RequestResult.Error -import dev.androidbroadcast.news.data.RequestResult.InProgress -import dev.androidbroadcast.news.data.RequestResult.Success - -public interface MergeStrategy { - public fun merge( - right: E, - left: E - ): E -} - -internal class RequestResponseMergeStrategy : MergeStrategy> { - @Suppress("CyclomaticComplexMethod") - override fun merge( - right: RequestResult, - left: RequestResult - ): RequestResult { - return when { - right is InProgress && left is InProgress -> merge(right, left) - right is Success && left is InProgress -> merge(right, left) - right is InProgress && left is Success -> merge(right, left) - right is Success && left is Success -> merge(right, left) - right is Success && left is Error -> merge(right, left) - right is InProgress && left is Error -> merge(right, left) - right is Error && left is InProgress -> merge(right, left) - right is Error && left is Success -> merge(right, left) - - else -> error("Unimplemented branch right=$right & left=$left") - } - } - - private fun merge( - cache: InProgress, - server: InProgress - ): RequestResult { - return when { - server.data != null -> InProgress(server.data) - else -> InProgress(cache.data) - } - } - - @Suppress("UNUSED_PARAMETER") - private fun merge( - cache: Success, - server: InProgress - ): RequestResult { - return InProgress(cache.data) - } - - @Suppress("UNUSED_PARAMETER") - private fun merge( - cache: InProgress, - server: Success - ): RequestResult { - return InProgress(server.data) - } - - private fun merge( - cache: Success, - server: Error - ): RequestResult { - return Error(data = cache.data, error = server.error) - } - - @Suppress("UNUSED_PARAMETER") - private fun merge( - cache: Success, - server: Success - ): RequestResult { - return Success(data = server.data) - } - - private fun merge( - cache: InProgress, - server: Error - ): RequestResult { - return Error(data = server.data ?: cache.data, error = server.error) - } - - private fun merge( - cache: Error, - server: InProgress - ): RequestResult { - return server - } - - private fun merge( - cache: Error, - server: Success - ): RequestResult { - return server - } -} diff --git a/news-data/src/main/java/dev/androidbroadcast/news/data/model/Article.kt b/news-data/src/main/java/dev/androidbroadcast/news/data/model/Article.kt index 50a6a47..acf2738 100644 --- a/news-data/src/main/java/dev/androidbroadcast/news/data/model/Article.kt +++ b/news-data/src/main/java/dev/androidbroadcast/news/data/model/Article.kt @@ -3,7 +3,7 @@ package dev.androidbroadcast.news.data.model import java.util.Date public data class Article( - val cacheId: Long = ID_NONE, + val cacheId: Int = ID_NONE, val source: Source, val author: String?, val title: String, @@ -17,7 +17,7 @@ public data class Article( /** * Специальный ID для обозначения что ID нету */ - public const val ID_NONE: Long = 0L + public const val ID_NONE: Int = 0 } } diff --git a/newsapi/src/main/java/dev/androidbroadcast/newsapi/NewsApi.kt b/newsapi/src/main/java/dev/androidbroadcast/newsapi/NewsApi.kt index 0b73de4..cd54bbd 100644 --- a/newsapi/src/main/java/dev/androidbroadcast/newsapi/NewsApi.kt +++ b/newsapi/src/main/java/dev/androidbroadcast/newsapi/NewsApi.kt @@ -34,9 +34,13 @@ interface NewsApi { @Query("to") to: Date? = null, @Query("languages") languages: List<@JvmSuppressWildcards Language>? = null, @Query("sortBy") sortBy: SortBy? = null, - @Query("pageSize") @IntRange(from = 0, to = 100) pageSize: Int = 100, + @Query("pageSize") @IntRange(from = 0, to = 100) pageSize: Int = MAX_PAGE_SIZE, @Query("page") @IntRange(from = 1) page: Int = 1 ): Result> + + companion object { + const val MAX_PAGE_SIZE = 100 + } } fun NewsApi(