diff --git a/README.md b/README.md index f66677d..7221cd5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Access the latest APK for Kotlin Dictionary from the link below. - [ ] Add a `Contributors Page` to showcase project contributors - [ ] Add a `Settings Page` with basic preferences - [ ] Implement a `Splash Screen` +- [x] Integrate multiplatform paging for `Topic Screen` --- diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index f29f12f..0b02eca 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -117,6 +117,8 @@ kotlin { implementation(libs.arrow.core) implementation(libs.arrow.fx.coroutines) implementation(project(":design-system")) + implementation(libs.cashapp.paging.common) + implementation(libs.cashapp.paging.compose.common) } desktopMain.dependencies { implementation(compose.desktop.currentOs) diff --git a/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/PreviewUtils.kt b/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/PreviewUtils.kt index 75e2f26..a9da3f4 100644 --- a/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/PreviewUtils.kt +++ b/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/PreviewUtils.kt @@ -1,11 +1,14 @@ package com.developersbreach.kotlindictionarymultiplatform.previews +import app.cash.paging.PagingData import com.developersbreach.kotlindictionarymultiplatform.data.detail.model.CodeExample import com.developersbreach.kotlindictionarymultiplatform.data.detail.model.KotlinTopicDetails import com.developersbreach.kotlindictionarymultiplatform.data.detail.model.Section import com.developersbreach.kotlindictionarymultiplatform.data.detail.model.Syntax -import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.Topic -import com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic.ItemTopic +import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.TopicResponse +import com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic.Topic +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf internal fun sampleCodeSnippet(): String { return """ @@ -51,37 +54,41 @@ internal fun fakeTopicDetails(): KotlinTopicDetails { ) } -private fun sampleTopicList(): List { +private fun sampleTopicList(): List { return listOf( - Topic( + TopicResponse( name = "Smart Casts", description = "Automatic casting by the compiler after type checks.", ), - Topic( + TopicResponse( name = "Null Safety", description = "Kotlin's system to eliminate null pointer exceptions at compile time.", ), - Topic( + TopicResponse( name = "Coroutines", description = "Lightweight threads for asynchronous and non-blocking programming.", ), - Topic( + TopicResponse( name = "Lambdas", description = "Anonymous functions used to pass behavior as data.", ), - Topic( + TopicResponse( name = "Sealed Classes", description = "Classes used to represent restricted class hierarchies for type safety.", ), ) } -internal fun sampleTopicUiList(): List { +internal fun sampleTopicUiList(): List { return sampleTopicList().map { topic -> - ItemTopic( + Topic( name = topic.name ?: "", initial = topic.name?.firstOrNull()?.uppercase() ?: "", description = topic.description ?: "", ) } +} + +internal fun samplePagingData(): Flow> { + return flowOf(PagingData.from(sampleTopicUiList())) } \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/topic/TopicScreenPreview.kt b/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/topic/TopicScreenPreview.kt index c34fd62..ffbe3d1 100644 --- a/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/topic/TopicScreenPreview.kt +++ b/composeApp/src/androidMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/previews/topic/TopicScreenPreview.kt @@ -2,7 +2,8 @@ package com.developersbreach.kotlindictionarymultiplatform.previews.topic import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.PreviewLightDark -import com.developersbreach.kotlindictionarymultiplatform.previews.sampleTopicUiList +import app.cash.paging.compose.collectAsLazyPagingItems +import com.developersbreach.kotlindictionarymultiplatform.previews.samplePagingData import com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic.TopicScreenUI import com.developersbreach.kotlindictionarymultiplatform.ui.theme.KotlinDictionaryTheme @@ -10,8 +11,9 @@ import com.developersbreach.kotlindictionarymultiplatform.ui.theme.KotlinDiction @Composable private fun TopicScreenPreview() { KotlinDictionaryTheme { + val pagingItems = samplePagingData().collectAsLazyPagingItems() TopicScreenUI( - topics = sampleTopicUiList(), + topics = pagingItems, searchQuery = "Search", onQueryChange = { }, onTopicClick = { }, diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/Topic.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/Topic.kt deleted file mode 100644 index 00bdb0c..0000000 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/Topic.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.developersbreach.kotlindictionarymultiplatform.data.topic.model - -import kotlinx.serialization.Serializable - -@Serializable -data class Topic( - val name: String?, - val description: String?, -) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/TopicResponse.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/TopicsResponse.kt similarity index 80% rename from composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/TopicResponse.kt rename to composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/TopicsResponse.kt index 16e5002..885f784 100644 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/TopicResponse.kt +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/model/TopicsResponse.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class TopicResponse( +data class TopicsResponse( @SerialName("documents") val topics: List, ) @@ -24,8 +24,14 @@ data class RawField( @SerialName("stringValue") val value: String, ) -fun RawTopic.toTopic(): Topic { - return Topic( +@Serializable +data class TopicResponse( + val name: String?, + val description: String?, +) + +fun RawTopic.toTopic(): TopicResponse { + return TopicResponse( name = fields.name.value, description = fields.description.value, ) diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/repository/TopicPagingSource.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/repository/TopicPagingSource.kt new file mode 100644 index 0000000..904ca40 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/repository/TopicPagingSource.kt @@ -0,0 +1,37 @@ +package com.developersbreach.kotlindictionarymultiplatform.data.topic.repository + +import app.cash.paging.PagingSource +import app.cash.paging.PagingState +import com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic.Topic + +class TopicPagingSource( + private val repository: TopicRepository, + private val query: String, +) : PagingSource() { + + override suspend fun load( + params: LoadParams, + ): LoadResult { + val page = params.key ?: 1 + val pageSize = params.loadSize + return try { + val pageItems = repository.getTopicsPage(page, pageSize, query) + LoadResult.Page( + data = pageItems, + prevKey = if (page == 1) null else page - 1, + nextKey = if (pageItems.isEmpty()) null else page + 1, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey( + state: PagingState, + ): Int? { + 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/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/repository/TopicRepository.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/repository/TopicRepository.kt index 16192dd..6d9f867 100644 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/repository/TopicRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/data/topic/repository/TopicRepository.kt @@ -1,10 +1,12 @@ package com.developersbreach.kotlindictionarymultiplatform.data.topic.repository import arrow.core.Either +import arrow.core.getOrElse +import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.TopicsResponse import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.TopicResponse -import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.Topic import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.toTopic import com.developersbreach.kotlindictionarymultiplatform.core.network.topicSource.FirestoreConstants +import com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic.Topic import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get @@ -12,10 +14,31 @@ import io.ktor.client.request.get class TopicRepository( private val httpClient: HttpClient, ) { - suspend fun getTopics(): Either> { + private suspend fun getTopics(): Either> { return Either.catch { - val topicResponse: TopicResponse = httpClient.get(FirestoreConstants.TOPICS_URL).body() - topicResponse.topics.map { it.toTopic() } + val topicsResponse: TopicsResponse = httpClient.get(FirestoreConstants.TOPICS_URL).body() + topicsResponse.topics.map { it.toTopic() } } } + + suspend fun getTopicsPage( + page: Int, + pageSize: Int, + query: String, + ): List { + val allTopics = getTopics().getOrElse { emptyList() } + val filteredTopics = allTopics + .filter { it.name?.contains(query, ignoreCase = true) == true } + .sortedBy { it.name?.lowercase() ?: "" } + .map { topic -> + Topic( + name = topic.name ?: "", + initial = topic.name?.firstOrNull()?.uppercase() ?: "", + description = topic.description ?: "", + ) + } + val fromIndex = (page - 1) * pageSize + val toIndex = (fromIndex + pageSize).coerceAtMost(filteredTopics.size) + return if (fromIndex < filteredTopics.size) filteredTopics.subList(fromIndex, toIndex) else emptyList() + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/Topic.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/Topic.kt new file mode 100644 index 0000000..973561b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/Topic.kt @@ -0,0 +1,7 @@ +package com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic + +data class Topic( + val name: String, + val initial: String, + val description: String, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicCard.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicCard.kt index 9998c81..80a737f 100644 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicCard.kt +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicCard.kt @@ -25,9 +25,7 @@ import com.developersbreach.designsystem.components.KdText @Composable fun TopicCard( - itemTopic: ItemTopic, - topic: String, - description: String, + topic: Topic, onCardClick: () -> Unit, ) { KdSurface( @@ -60,7 +58,7 @@ fun TopicCard( ) { KdText( modifier = Modifier, - text = itemTopic.initial, + text = topic.initial, ) } @@ -71,7 +69,7 @@ fun TopicCard( ) { KdText( modifier = Modifier, - text = topic, + text = topic.name, style = MaterialTheme.typography.headlineMedium.copy( color = MaterialTheme.colorScheme.onPrimary, ), @@ -81,7 +79,7 @@ fun TopicCard( Spacer(modifier = Modifier.height(6.dp)) KdText( modifier = Modifier, - text = description, + text = topic.description, style = MaterialTheme.typography.labelMedium.copy( color = MaterialTheme.colorScheme.onBackground, ), diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicList.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicList.kt index c916049..c932c62 100644 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicList.kt +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicList.kt @@ -3,27 +3,28 @@ package com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import app.cash.paging.compose.LazyPagingItems @Composable fun TopicList( - topics: List, + topics: LazyPagingItems, onTopicClick: (String) -> Unit, ) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 40.dp), ) { - items(topics) { topic -> - TopicCard( - topic = topic.name, - itemTopic = topic, - description = topic.description, - onCardClick = { onTopicClick(topic.name) }, - ) + items(topics.itemCount) { index -> + val topic = topics[index] + topic?.let { + TopicCard( + topic = it, + onCardClick = { onTopicClick(it.name) }, + ) + } } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreen.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreen.kt index efebf55..f1788a9 100644 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreen.kt @@ -2,24 +2,20 @@ package com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import com.developersbreach.kotlindictionarymultiplatform.ui.components.UiStateHandler +import app.cash.paging.compose.collectAsLazyPagingItems @Composable fun TopicScreen( viewModel: TopicViewModel, onTopicClick: (String) -> Unit, ) { - val uiState by viewModel.uiState.collectAsState() + val pagingItems = viewModel.topics.collectAsLazyPagingItems() + val searchQuery = viewModel.searchQuery.collectAsState().value - UiStateHandler( - uiState = uiState, - ) { data -> - TopicScreenUI( - topics = data.filteredTopics, - searchQuery = data.searchQuery, - onQueryChange = viewModel::updateSearchQuery, - onTopicClick = onTopicClick, - ) - } + TopicScreenUI( + topics = pagingItems, + searchQuery = searchQuery, + onQueryChange = viewModel::updateSearchQuery, + onTopicClick = onTopicClick, + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreenUI.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreenUI.kt index 770320b..7084245 100644 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreenUI.kt +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicScreenUI.kt @@ -8,10 +8,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.developersbreach.designsystem.components.KdScaffold +import app.cash.paging.compose.LazyPagingItems @Composable fun TopicScreenUI( - topics: List, + topics: LazyPagingItems, searchQuery: String, onQueryChange: (String) -> Unit, onTopicClick: (String) -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicUiState.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicUiState.kt deleted file mode 100644 index d0bd8cd..0000000 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicUiState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic - -import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.Topic - -data class TopicUi( - val isLoading: Boolean = false, - val topics: List = emptyList(), - val searchQuery: String = "", - val filteredTopics: List = emptyList(), -) - -data class ItemTopic( - val name: String, - val initial: String, - val description: String, -) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicViewModel.kt b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicViewModel.kt index 1476fcd..034d36e 100644 --- a/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/developersbreach/kotlindictionarymultiplatform/ui/screens/topic/TopicViewModel.kt @@ -2,68 +2,44 @@ package com.developersbreach.kotlindictionarymultiplatform.ui.screens.topic import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.developersbreach.kotlindictionarymultiplatform.data.topic.model.Topic +import app.cash.paging.Pager +import app.cash.paging.PagingConfig +import app.cash.paging.PagingData +import com.developersbreach.kotlindictionarymultiplatform.data.topic.repository.TopicPagingSource import com.developersbreach.kotlindictionarymultiplatform.data.topic.repository.TopicRepository -import com.developersbreach.kotlindictionarymultiplatform.ui.components.UiState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn class TopicViewModel( private val repository: TopicRepository, ) : ViewModel() { - private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Loading) - val uiState: StateFlow> = _uiState + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() - private var rawTopics: List = emptyList() - - init { - viewModelScope.launch { - fetchTopicList() + @OptIn(ExperimentalCoroutinesApi::class) + val topics: Flow> = searchQuery + .flatMapLatest { query -> + Pager( + config = PagingConfig(pageSize = 8), + pagingSourceFactory = { TopicPagingSource(repository, query) }, + ).flow } - } - - private suspend fun fetchTopicList() { - _uiState.value = UiState.Success(TopicUi(isLoading = true)) - repository.getTopics().fold( - ifLeft = { UiState.Error(it) }, - ifRight = { list -> - rawTopics = list.sortedBy { it.name?.lowercase() ?: "" } - applyFilters(rawTopics, (_uiState.value as UiState.Success).data.searchQuery) - }, + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + PagingData.empty(), ) - } fun updateSearchQuery( newQuery: String, ) { - applyFilters(rawTopics, newQuery) - } - - private fun applyFilters( - topics: List, - query: String, - ) { - val filtered = topics - .filter { topic -> - topic.name?.contains(query, ignoreCase = true) == true - } - .map { topic -> - ItemTopic( - name = topic.name ?: "", - initial = topic.name?.firstOrNull()?.uppercase() ?: "", - description = topic.description ?: "", - ) - } - - _uiState.value = (_uiState.value as UiState.Success).copy( - TopicUi( - isLoading = false, - searchQuery = query, - topics = topics, - filteredTopics = filtered, - ), - ) + _searchQuery.value = newQuery } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f49d58..4e07c4d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ kotlinStdlib = "2.1.10" runner = "1.6.2" core = "1.6.1" uiToolingPreviewAndroid = "1.8.2" +cashapp-paging = "3.3.0-alpha02-0.5.1" [libraries] androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } @@ -70,6 +71,8 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" } +cashapp-paging-common = { module = "app.cash.paging:paging-common", version.ref = "cashapp-paging" } +cashapp-paging-compose-common = { module = "app.cash.paging:paging-compose-common", version.ref = "cashapp-paging" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }