From 0887041c6f1efdfb4588f2a5f5567bf460570e76 Mon Sep 17 00:00:00 2001 From: DatLag Date: Thu, 23 May 2024 00:03:32 +0200 Subject: [PATCH] list repository re-write with flowredux --- .../src/commonMain/graphql/ListQuery.graphql | 7 +- .../datlag/aniflow/anilist/ListRepository.kt | 169 ------------------ .../aniflow/anilist/ListStateMachine.kt | 168 +++++++++++++++++ .../anilist/RecommendationRepository.kt | 4 +- .../aniflow/anilist/SearchStateMachine.kt | 48 +++-- .../dev/datlag/aniflow/anilist/StateSaver.kt | 3 + .../aniflow/anilist/common/ExtendOptional.kt | 12 ++ .../aniflow/anilist/model/PageListQuery.kt | 33 ++++ .../datlag/aniflow/anilist/state/ListState.kt | 96 ++++++++++ .../aniflow/anilist/state/SearchState.kt | 15 +- .../datlag/aniflow/module/NetworkModule.kt | 24 +-- .../aniflow/ui/custom/InfiniteListHandler.kt | 46 +++++ .../discover/DiscoverScreenComponent.kt | 4 +- .../discover/component/HidingSearchBar.kt | 1 - .../screen/favorites/FavoritesComponent.kt | 5 +- .../screen/favorites/FavoritesScreen.kt | 102 ++++++++--- .../favorites/FavoritesScreenComponent.kt | 28 ++- 17 files changed, 515 insertions(+), 250 deletions(-) delete mode 100644 anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListRepository.kt create mode 100644 anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListStateMachine.kt create mode 100644 anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/PageListQuery.kt create mode 100644 anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/ListState.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/InfiniteListHandler.kt diff --git a/anilist/src/commonMain/graphql/ListQuery.graphql b/anilist/src/commonMain/graphql/ListQuery.graphql index 849d702..8983d9b 100644 --- a/anilist/src/commonMain/graphql/ListQuery.graphql +++ b/anilist/src/commonMain/graphql/ListQuery.graphql @@ -2,12 +2,13 @@ query ListQuery( $type: MediaType, $userId: Int!, $page: Int, + $perPage: Int, $status: MediaListStatus, - $html: Boolean, - $statusVersion: Int, + $html: Boolean!, + $statusVersion: Int!, $sort: [MediaListSort] ) { - Page(page: $page) { + Page(page: $page, perPage: $perPage) { pageInfo { hasNextPage }, diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListRepository.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListRepository.kt deleted file mode 100644 index d969929..0000000 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListRepository.kt +++ /dev/null @@ -1,169 +0,0 @@ -package dev.datlag.aniflow.anilist - -import com.apollographql.apollo3.ApolloClient -import com.apollographql.apollo3.api.Optional -import dev.datlag.aniflow.anilist.model.Medium -import dev.datlag.aniflow.anilist.model.User -import dev.datlag.aniflow.anilist.type.MediaListSort -import dev.datlag.aniflow.anilist.type.MediaListStatus -import dev.datlag.aniflow.anilist.type.MediaType -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.* - -class ListRepository( - private val client: ApolloClient, - private val fallbackClient: ApolloClient, - private val user: Flow, - private val viewManga: Flow = flowOf(false), -) { - - private val page = MutableStateFlow(0) - private val _type = MutableStateFlow(MediaType.UNKNOWN__) - private val sort = MutableStateFlow(MediaListSort.UPDATED_TIME_DESC) - val status = MutableStateFlow(MediaListStatus.UNKNOWN__) - - @OptIn(ExperimentalCoroutinesApi::class) - val type = _type.transformLatest { - return@transformLatest if (it == MediaType.UNKNOWN__) { - emitAll(viewManga.map { m -> - if (m) { - MediaType.MANGA - } else { - MediaType.ANIME - } - }) - } else { - emit(it) - } - }.distinctUntilChanged() - - private val query = combine( - page, - type, - sort, - status, - user.mapNotNull { it?.id }.distinctUntilChanged(), - ) { p, t, s, l, u -> - Query( - page = p, - type = t, - userId = u, - sort = s, - status = l - ) - } - - @OptIn(ExperimentalCoroutinesApi::class) - private val fallbackQuery = query.transformLatest { - return@transformLatest emitAll(fallbackClient.query(it.toGraphQL()).toFlow()) - }.mapNotNull { - val data = it.data - if (data == null) { - if (it.hasErrors()) { - State.fromGraphQL(data) - } else { - null - } - } else { - State.fromGraphQL(data) - } - } - - @OptIn(ExperimentalCoroutinesApi::class) - val list = query.transformLatest { - return@transformLatest emitAll(client.query(it.toGraphQL()).toFlow()) - }.mapNotNull { - val data = it.data - if (data == null) { - if (it.hasErrors()) { - State.fromGraphQL(data) - } else { - null - } - } else { - State.fromGraphQL(data) - } - }.transformLatest { - return@transformLatest if (it is State.Error) { - emitAll(fallbackQuery) - } else { - emit(it) - } - } - - fun setType(type: MediaType) { - _type.update { type } - } - - fun viewAnime() = setType(MediaType.ANIME) - fun viewManga() = setType(MediaType.MANGA) - - fun toggleType() { - _type.update { - if (it == MediaType.MANGA) { - MediaType.ANIME - } else { - MediaType.MANGA - } - } - } - - fun setStatus(status: MediaListStatus) { - this.status.update { status } - } - - private data class Query( - val page: Int, - val type: MediaType, - val userId: Int, - val sort: MediaListSort, - val status: MediaListStatus - ) { - fun toGraphQL() = ListQuery( - page = Optional.present(page), - type = if (type == MediaType.UNKNOWN__) { - Optional.absent() - } else { - Optional.present(type) - }, - userId = userId, - sort = if (sort == MediaListSort.UNKNOWN__) { - Optional.absent() - } else { - Optional.present(listOf(sort)) - }, - status = if (status == MediaListStatus.UNKNOWN__) { - Optional.absent() - } else { - Optional.present(status) - } - ) - } - - sealed interface State { - data object None : State - - data class Success( - val hasNextPage: Boolean, - val medium: Collection - ) : State - - data object Error : State - - companion object { - fun fromGraphQL(query: ListQuery.Data?): State { - val medium = query?.Page?.mediaListFilterNotNull()?.mapNotNull { - Medium( - media = it.media ?: return@mapNotNull null, - list = it - ) - } ?: return Error - - return Success( - hasNextPage = query.Page.pageInfo?.hasNextPage ?: false, - medium = medium.distinctBy { it.id } - ) - } - } - } -} \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListStateMachine.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListStateMachine.kt new file mode 100644 index 0000000..1ea0117 --- /dev/null +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/ListStateMachine.kt @@ -0,0 +1,168 @@ +package dev.datlag.aniflow.anilist + +import com.apollographql.apollo3.ApolloClient +import com.freeletics.flowredux.dsl.FlowReduxStateMachine +import dev.datlag.aniflow.anilist.model.PageListQuery +import dev.datlag.aniflow.anilist.model.User +import dev.datlag.aniflow.anilist.state.ListAction +import dev.datlag.aniflow.anilist.state.ListState +import dev.datlag.aniflow.anilist.type.MediaListStatus +import dev.datlag.aniflow.anilist.type.MediaType +import dev.datlag.aniflow.firebase.FirebaseFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update + +@OptIn(ExperimentalCoroutinesApi::class) +class ListStateMachine( + private val client: ApolloClient, + private val fallbackClient: ApolloClient, + private val user: Flow, + private val viewManga: Flow = flowOf(false), + private val crashlytics: FirebaseFactory.Crashlytics?, +) : FlowReduxStateMachine( + initialState = currentState +) { + + var currentState: ListState + get() = Companion.currentState + private set(value) { + Companion.currentState = value + } + + private val page = MutableStateFlow(0) + private val _type = MutableStateFlow(MediaType.UNKNOWN__) + private val _status = MutableStateFlow(MediaListStatus.UNKNOWN__) + val status: StateFlow = _status + + val type = _type.transformLatest { + return@transformLatest if (it == MediaType.UNKNOWN__) { + emitAll(viewManga.map { m -> + if (m) { + MediaType.MANGA + } else { + MediaType.ANIME + } + }) + } else { + emit(it) + } + }.distinctUntilChanged() + + private val query = combine( + page, + type, + _status, + user.mapNotNull { it?.id }.distinctUntilChanged() + ) { p, t, s, u -> + PageListQuery.ForPage( + page = p, + type = t, + status = s, + userId = u + ) + }.distinctUntilChanged() + + init { + spec { + inState { + onEnterEffect { + currentState = it + } + onActionEffect { action, _ -> + when (action) { + is ListAction.Page.Next -> { + page.update { it + 1 } + } + } + } + onActionEffect { action, _ -> + when (action) { + is ListAction.Type.Anime -> { + page.update { 0 } + _type.update { + MediaType.ANIME + } + } + is ListAction.Type.Manga -> { + page.update { 0 } + _type.update { + MediaType.MANGA + } + } + is ListAction.Type.Toggle -> { + page.update { 0 } + _type.update { + if (it == MediaType.MANGA) { + MediaType.ANIME + } else { + MediaType.MANGA + } + } + } + } + } + onActionEffect { action, _ -> + page.update { 0 } + _status.update { + action.value + } + } + collectWhileInState(query) { q, state -> + state.override { + val collection = if (q.page == 0) { + emptyList() + } else { + (this as? ListState.Data)?.collection.orEmpty() + } + + ListState.Loading( + query = q, + fallback = false, + collection = collection + ) + } + } + } + inState { + collectWhileInState( + flowBuilder = { + val usedClient = if (it.fallback) { + fallbackClient + } else { + client + } + + usedClient.query(it.query.toGraphQL()).toFlow() + } + ) { response, state -> + state.override { + fromGraphQL(response) + } + } + } + inState { + onEnterEffect { + crashlytics?.log(it.throwable) + } + } + } + } + + companion object { + var currentState + get() = StateSaver.listState + private set(value) { + StateSaver.listState = value + } + } +} \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/RecommendationRepository.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/RecommendationRepository.kt index 9eddc8e..0f43c88 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/RecommendationRepository.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/RecommendationRepository.kt @@ -149,7 +149,9 @@ class RecommendationRepository( Optional.present(type) }, userId = userId, - sort = Optional.present(listOf(MediaListSort.FINISHED_ON_DESC, MediaListSort.UPDATED_TIME_DESC)) + sort = Optional.present(listOf(MediaListSort.FINISHED_ON_DESC, MediaListSort.UPDATED_TIME_DESC)), + statusVersion = 2, + html = true ) } diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchStateMachine.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchStateMachine.kt index d3ebc81..4530386 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchStateMachine.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/SearchStateMachine.kt @@ -27,8 +27,7 @@ class SearchStateMachine( private val fallbackClient: ApolloClient, private val nsfw: Flow = flowOf(false), private val viewManga: Flow = flowOf(false), - private val crashlytics: FirebaseFactory.Crashlytics?, - private val log: (String) -> Unit + private val crashlytics: FirebaseFactory.Crashlytics? ) : FlowReduxStateMachine( initialState = currentState ) { @@ -90,21 +89,6 @@ class SearchStateMachine( onEnterEffect { currentState = it } - onActionEffect { action, _ -> - when (action) { - is SearchAction.Type.Anime -> _type.update { MediaType.ANIME } - is SearchAction.Type.Manga -> _type.update { MediaType.MANGA } - is SearchAction.Type.Toggle -> { - _type.update { - if (it == MediaType.MANGA) { - MediaType.ANIME - } else { - MediaType.MANGA - } - } - } - } - } collectWhileInState(query) { q, state -> state.override { if (q == null) { @@ -146,10 +130,40 @@ class SearchStateMachine( } } + /** + * Don't use action as state may not be collected while changing data. + */ fun search(query: String) { StateSaver.searchQuery = _search.updateAndGet { query.trim() }?.ifBlank { null } } + /** + * Don't use action as state may not be collected while changing data. + */ + fun viewAnime() { + _type.update { MediaType.ANIME } + } + + /** + * Don't use action as state may not be collected while changing data. + */ + fun viewManga() { + _type.update { MediaType.MANGA } + } + + /** + * Don't use action as state may not be collected while changing data. + */ + fun toggleType() { + _type.update { + if (it == MediaType.MANGA) { + MediaType.ANIME + } else { + MediaType.MANGA + } + } + } + companion object { var currentState: SearchState get() = StateSaver.searchState diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/StateSaver.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/StateSaver.kt index 4899c03..bb9d9da 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/StateSaver.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/StateSaver.kt @@ -2,6 +2,7 @@ package dev.datlag.aniflow.anilist import dev.datlag.aniflow.anilist.state.HomeAiringState import dev.datlag.aniflow.anilist.state.HomeDefaultState +import dev.datlag.aniflow.anilist.state.ListState import dev.datlag.aniflow.anilist.state.SearchState internal object StateSaver { @@ -13,4 +14,6 @@ internal object StateSaver { var searchState: SearchState = SearchState.None var searchQuery: String? = null + + var listState: ListState = ListState.None } \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendOptional.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendOptional.kt index 9ae091f..acbacac 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendOptional.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/common/ExtendOptional.kt @@ -1,6 +1,8 @@ package dev.datlag.aniflow.anilist.common import com.apollographql.apollo3.api.Optional +import dev.datlag.aniflow.anilist.type.MediaListSort +import dev.datlag.aniflow.anilist.type.MediaListStatus import dev.datlag.aniflow.anilist.type.MediaSeason import dev.datlag.aniflow.anilist.type.MediaType @@ -21,6 +23,16 @@ fun Optional.Companion.presentMediaType(type: MediaType) = when (type) { else -> present(type) } +fun Optional.Companion.presentMediaListSort(type: MediaListSort) = when (type) { + MediaListSort.UNKNOWN__ -> absent() + else -> present(listOf(type)) +} + +fun Optional.Companion.presentMediaListStatus(type: MediaListStatus) = when (type) { + MediaListStatus.UNKNOWN__ -> absent() + else -> present(type) +} + fun Optional.Companion.presentMediaSeason(type: MediaSeason) = when (type) { MediaSeason.UNKNOWN__ -> absent() else -> present(type) diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/PageListQuery.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/PageListQuery.kt new file mode 100644 index 0000000..36adb68 --- /dev/null +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/model/PageListQuery.kt @@ -0,0 +1,33 @@ +package dev.datlag.aniflow.anilist.model + +import com.apollographql.apollo3.api.Optional +import dev.datlag.aniflow.anilist.ListQuery +import dev.datlag.aniflow.anilist.common.presentMediaListSort +import dev.datlag.aniflow.anilist.common.presentMediaListStatus +import dev.datlag.aniflow.anilist.common.presentMediaType +import dev.datlag.aniflow.anilist.type.MediaListSort +import dev.datlag.aniflow.anilist.type.MediaListStatus +import dev.datlag.aniflow.anilist.type.MediaType + +sealed interface PageListQuery { + + fun toGraphQL(): ListQuery + + data class ForPage( + val page: Int, + val type: MediaType, + val userId: Int, + val status: MediaListStatus + ) : PageListQuery { + override fun toGraphQL(): ListQuery = ListQuery( + page = Optional.present(page), + perPage = Optional.present(20), + userId = userId, + type = Optional.presentMediaType(type), + sort = Optional.presentMediaListSort(MediaListSort.UPDATED_TIME_DESC), + status = Optional.presentMediaListStatus(status), + statusVersion = 2, + html = true + ) + } +} \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/ListState.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/ListState.kt new file mode 100644 index 0000000..2f12fa8 --- /dev/null +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/ListState.kt @@ -0,0 +1,96 @@ +package dev.datlag.aniflow.anilist.state + +import com.apollographql.apollo3.api.ApolloResponse +import dev.datlag.aniflow.anilist.ListQuery +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.model.PageListQuery +import dev.datlag.aniflow.anilist.type.MediaListStatus + +sealed interface ListState { + + data object None : ListState + + sealed interface Data : ListState { + val hasNextPage: Boolean + val collection: Collection + } + + data class Loading( + internal val query: PageListQuery, + internal val fallback: Boolean, + override val collection: Collection + ) : Data { + override val hasNextPage: Boolean = false + + fun fromGraphQL(response: ApolloResponse): ListState { + val data = response.data + + return if (data == null) { + if (fallback) { + Error( + throwable = response.exception, + collection = collection + ) + } else { + copy(fallback = true) + } + } else { + val mediaList = data.Page?.mediaListFilterNotNull() + + if (mediaList.isNullOrEmpty()) { + if (fallback) { + Error( + throwable = response.exception, + collection = collection + ) + } else { + copy(fallback = true) + } + } else { + Success( + hasNextPage = data.Page.pageInfo?.hasNextPage ?: false, + collection = (collection + mediaList.mapNotNull { + Medium( + media = it.media ?: return@mapNotNull null, + list = it + ) + }).distinctBy { it.id } + ) + } + } + } + } + + private sealed interface PostLoading : Data + + data class Success( + override val hasNextPage: Boolean, + override val collection: Collection + ) : PostLoading + + data class Error( + internal val throwable: Throwable?, + override val collection: Collection + ) : PostLoading { + override val hasNextPage: Boolean = false + } +} + +sealed interface ListAction { + + sealed interface Page : ListAction { + + data object Next : Page + } + + sealed interface Type : ListAction { + + data object Anime : Type + + data object Manga : Type + + data object Toggle : Type + } + + data class Status(internal val value: MediaListStatus) : ListAction +} \ No newline at end of file diff --git a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SearchState.kt b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SearchState.kt index 7545f05..5cdaad2 100644 --- a/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SearchState.kt +++ b/anilist/src/commonMain/kotlin/dev/datlag/aniflow/anilist/state/SearchState.kt @@ -56,14 +56,7 @@ sealed interface SearchState { ) : PostLoading } -sealed interface SearchAction { - - sealed interface Type : SearchAction { - - data object Anime : Type - - data object Manga : Type - - data object Toggle : Type - } -} \ No newline at end of file +/** + * Don't use action as state may not be collected while changing data. + */ +sealed interface SearchAction { } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt index 294a8d8..b3a36af 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/module/NetworkModule.kt @@ -154,16 +154,6 @@ data object NetworkModule { nsfw = appSettings.adultContent ) } - bindSingleton { - val appSettings = instance() - - ListRepository( - client = instance(Constants.AniList.APOLLO_CLIENT), - fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT).newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build(), - user = instance().user, - viewManga = appSettings.viewManga - ) - } bindSingleton { val appSettings = instance() @@ -227,8 +217,18 @@ data object NetworkModule { fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT), nsfw = appSettings.adultContent, viewManga = appSettings.viewManga, - crashlytics = nullableFirebaseInstance()?.crashlytics, - log = Napier::e + crashlytics = nullableFirebaseInstance()?.crashlytics + ) + } + bindProvider { + val appSettings = instance() + + ListStateMachine( + client = instance(Constants.AniList.APOLLO_CLIENT), + fallbackClient = instance(Constants.AniList.FALLBACK_APOLLO_CLIENT).newBuilder().fetchPolicy(FetchPolicy.NetworkFirst).build(), + user = instance().user, + viewManga = appSettings.viewManga, + crashlytics = nullableFirebaseInstance()?.crashlytics ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/InfiniteListHandler.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/InfiniteListHandler.kt new file mode 100644 index 0000000..aa1adfe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/custom/InfiniteListHandler.kt @@ -0,0 +1,46 @@ +package dev.datlag.aniflow.ui.custom + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter + +/** + * Handler to make any lazy column (or lazy row) infinite. Will notify the [onLoadMore] + * callback once needed + * @param listState state of the list that needs to also be passed to the LazyColumn composable. + * Get it by calling rememberLazyListState() + * @param buffer the number of items before the end of the list to call the onLoadMore callback + * @param onLoadMore will notify when we need to load more + */ +@Composable +fun InfiniteListHandler( + listState: LazyListState, + buffer: Int = 2, + canLoadMore: Boolean = true, + onLoadMore: suspend () -> Unit +) { + if (canLoadMore) { + val loadMore = remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItemsNumber = layoutInfo.totalItemsCount + val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1 + + lastVisibleItemIndex > (totalItemsNumber - buffer) + } + } + + LaunchedEffect(loadMore) { + snapshotFlow { loadMore.value } + .filter { it } + .collect { + onLoadMore() + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt index 9b6734d..9e0bc77 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/DiscoverScreenComponent.kt @@ -90,8 +90,6 @@ class DiscoverScreenComponent( } override fun toggleView() { - launchIO { - searchRepository.dispatch(SearchAction.Type.Toggle) - } + searchRepository.toggleType() } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/HidingSearchBar.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/HidingSearchBar.kt index 61fe83c..5d10e45 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/HidingSearchBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/discover/component/HidingSearchBar.kt @@ -146,7 +146,6 @@ fun HidingSearchBar( } IconButton( onClick = { - searchBarState.isActive = true component.toggleView() }, enabled = type != MediaType.UNKNOWN__ diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt index a9aa32f..1c94240 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesComponent.kt @@ -1,7 +1,7 @@ package dev.datlag.aniflow.ui.navigation.screen.favorites -import dev.datlag.aniflow.anilist.ListRepository import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.state.ListState import dev.datlag.aniflow.anilist.type.MediaListStatus import dev.datlag.aniflow.anilist.type.MediaType import dev.datlag.aniflow.settings.model.TitleLanguage @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow interface FavoritesComponent : Component { - val listState: StateFlow + val listState: StateFlow val titleLanguage: Flow val type: Flow val status: Flow @@ -23,4 +23,5 @@ interface FavoritesComponent : Component { fun toggleView() fun setStatus(status: MediaListStatus) + suspend fun nextPage() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt index 9ed279a..9aa6fa1 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreen.kt @@ -2,6 +2,7 @@ package dev.datlag.aniflow.ui.navigation.screen.favorites import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -9,11 +10,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.MenuBook import androidx.compose.material.icons.rounded.ClearAll import androidx.compose.material.icons.rounded.FilterList -import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlayCircle -import androidx.compose.material.icons.rounded.SwapVert import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -35,11 +35,14 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.HazeMaterials import dev.datlag.aniflow.LocalHaze import dev.datlag.aniflow.SharedRes -import dev.datlag.aniflow.anilist.ListRepository +import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.state.ListState import dev.datlag.aniflow.anilist.type.MediaListStatus import dev.datlag.aniflow.anilist.type.MediaType import dev.datlag.aniflow.common.* +import dev.datlag.aniflow.settings.model.TitleLanguage import dev.datlag.aniflow.ui.custom.ErrorContent +import dev.datlag.aniflow.ui.custom.InfiniteListHandler import dev.datlag.aniflow.ui.navigation.screen.component.HidingNavigationBar import dev.datlag.aniflow.ui.navigation.screen.component.NavigationBarState import dev.datlag.aniflow.ui.navigation.screen.favorites.component.ListCard @@ -153,7 +156,7 @@ fun FavoritesScreen(component: FavoritesComponent) { val state by component.listState.collectAsStateWithLifecycle() when (val current = state) { - is ListRepository.State.None -> { + is ListState.None -> { Box( modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center @@ -163,31 +166,86 @@ fun FavoritesScreen(component: FavoritesComponent) { ) } } - is ListRepository.State.Error -> { - ErrorContent( - modifier = Modifier.fillMaxSize() + is ListState.Data -> { + ListData( + state = current, + listState = listState, + padding = padding, + titleLanguage = null, + onClick = component::details, + onIncrease = component::increase, + onLoadMore = component::nextPage ) } - is ListRepository.State.Success -> { - val titleLanguage by component.titleLanguage.collectAsStateWithLifecycle(null) + } + } +} - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().haze(state = LocalHaze.current), - contentPadding = padding.merge(PaddingValues(16.dp)), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(current.medium.toList(), key = { it.id }) { - ListCard( - medium = it, - titleLanguage = titleLanguage, - modifier = Modifier.fillParentMaxWidth().height(150.dp), - onClick = component::details, - onIncrease = component::increase +@Composable +private fun ListData( + state: ListState.Data, + listState: LazyListState, + padding: PaddingValues, + titleLanguage: TitleLanguage?, + onClick: (Medium) -> Unit, + onIncrease: (Medium, Int) -> Unit, + onLoadMore: suspend () -> Unit +) { + val collection = remember(state) { state.collection } + + if (collection.isNotEmpty()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().haze(state = LocalHaze.current), + contentPadding = padding.merge(PaddingValues(16.dp)), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(collection.toList(), key = { it.id }) { + ListCard( + medium = it, + titleLanguage = titleLanguage, + modifier = Modifier.fillParentMaxWidth().height(150.dp), + onClick = onClick, + onIncrease = onIncrease + ) + } + if (state is ListState.Loading) { + item { + Box( + modifier = Modifier.fillParentMaxWidth().padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(fraction = 0.2F).clip(CircleShape) ) } } } } + + InfiniteListHandler( + listState = listState, + canLoadMore = state.hasNextPage, + onLoadMore = onLoadMore + ) + } else { + when (state) { + is ListState.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(0.2F).clip(CircleShape) + ) + } + } + is ListState.Error -> { + ErrorContent( + modifier = Modifier.fillMaxSize() + ) + } + else -> { } + } } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt index 80a1c78..2b0f59d 100644 --- a/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt +++ b/composeApp/src/commonMain/kotlin/dev/datlag/aniflow/ui/navigation/screen/favorites/FavoritesScreenComponent.kt @@ -9,20 +9,20 @@ import com.arkivanov.decompose.ComponentContext import dev.chrisbanes.haze.HazeState import dev.datlag.aniflow.LocalHaze import dev.datlag.aniflow.anilist.EditMutation -import dev.datlag.aniflow.anilist.ListRepository +import dev.datlag.aniflow.anilist.ListStateMachine import dev.datlag.aniflow.anilist.model.Medium +import dev.datlag.aniflow.anilist.state.ListAction +import dev.datlag.aniflow.anilist.state.ListState import dev.datlag.aniflow.anilist.type.MediaListStatus import dev.datlag.aniflow.anilist.type.MediaType import dev.datlag.aniflow.common.onRender import dev.datlag.aniflow.model.coroutines.Executor import dev.datlag.aniflow.other.Constants -import dev.datlag.aniflow.other.UserHelper import dev.datlag.aniflow.settings.Settings import dev.datlag.aniflow.settings.model.TitleLanguage import dev.datlag.tooling.compose.ioDispatcher -import dev.datlag.tooling.compose.withMainContext +import dev.datlag.tooling.compose.withIOContext import dev.datlag.tooling.decompose.ioScope -import io.github.aakira.napier.Napier import kotlinx.coroutines.flow.* import org.kodein.di.DI import org.kodein.di.instance @@ -39,13 +39,13 @@ class FavoritesScreenComponent( override val titleLanguage: Flow = appSettings.titleLanguage private val apolloClient by instance(Constants.AniList.APOLLO_CLIENT) - private val listRepository by instance() - override val listState: StateFlow = listRepository.list.flowOn( + private val listRepository by instance() + override val listState: StateFlow = listRepository.state.flowOn( context = ioDispatcher() ).stateIn( scope = ioScope(), started = SharingStarted.WhileSubscribed(), - initialValue = ListRepository.State.None + initialValue = listRepository.currentState ) private val increaseExecutor = Executor() @@ -106,10 +106,20 @@ class FavoritesScreenComponent( } override fun toggleView() { - listRepository.toggleType() + launchIO { + listRepository.dispatch(ListAction.Type.Toggle) + } } override fun setStatus(status: MediaListStatus) { - listRepository.setStatus(status) + launchIO { + listRepository.dispatch(ListAction.Status(status)) + } + } + + override suspend fun nextPage() { + withIOContext { + listRepository.dispatch(ListAction.Page.Next) + } } } \ No newline at end of file