Skip to content

Instantly share code, notes, and snippets.

@electrolobzik
Created February 27, 2024 16:29
Show Gist options
  • Save electrolobzik/8976a32c31b79ff638e4d7ad6c10eb34 to your computer and use it in GitHub Desktop.
Save electrolobzik/8976a32c31b79ff638e4d7ad6c10eb34 to your computer and use it in GitHub Desktop.
/**
* Created by Roman Chernyak (aka @electrolobzik) on 2024-02-22
*/
class Paginator<Data : Any, Cursor : Any>(
coroutineContext: CoroutineContext
) : CoroutineScope by CoroutineScope(coroutineContext) {
private val _state = MutableStateFlow<State<Data, Cursor>>(State.NoData.Empty())
private val _sideEffects = MutableSharedFlow<SideEffect<Cursor>>()
private val inputActions = MutableSharedFlow<Action<Data, Cursor>>()
val state: StateFlow<State<Data, Cursor>> = _state
val sideEffects: Flow<SideEffect<Cursor>> = _sideEffects
init {
launch {
inputActions.collect {
handleAction(it)
}
}
}
fun sendAction(action: Action<Data, Cursor>) {
launch {
inputActions.emit(action)
}
}
private suspend fun handleAction(action: Action<Data, Cursor>) {
val currentState = state.value
log("==> handleAction: action: ${action::class.simpleName}, current state = ${currentState::class.simpleName}")
val newState = reduce(action, currentState)
val sideEffect = getSideEffect(action, currentState)
log(" New state: $newState")
if (newState != state) {
_state.emit(newState)
}
log(" Side effect = $sideEffect")
if (sideEffect != null) {
_sideEffects.emit(sideEffect)
}
log("<== handleAction: old state: ${currentState::class.simpleName}, new state = ${newState::class.simpleName}")
}
sealed interface State<Data, Cursor> {
sealed interface NoData<Data, Cursor> : State<Data, Cursor> {
class Empty<Data, Cursor> : NoData<Data, Cursor>
class EmptyLoading<Data, Cursor> : NoData<Data, Cursor>
data class EmptyError<Data, Cursor>(val error: LoadingError) : NoData<Data, Cursor>
}
sealed interface WithData<Data, Cursor> : State<Data, Cursor> {
val lastPage: Cursor
val data: List<Data>
data class Data<Data, Cursor>(override val lastPage: Cursor, override val data: List<Data>) : WithData<Data, Cursor>
data class DataAndRefreshInProgress<Data, Cursor>(override val lastPage: Cursor, override val data: List<Data>) : WithData<Data, Cursor>
data class DataAndNewPageInProgress<Data, Cursor>(override val lastPage: Cursor, override val data: List<Data>) : WithData<Data, Cursor>
data class FullData<Data, Cursor>(override val lastPage: Cursor, override val data: List<Data>) : WithData<Data, Cursor>
data class FullDataAndRefreshInProgress<Data, Cursor>(override val lastPage: Cursor, override val data: List<Data>) :
WithData<Data, Cursor>
}
fun toExternalState(): DataState<Data> = when (this) {
is NoData.Empty -> DataState.NoData.Empty()
is NoData.EmptyError -> DataState.NoData.EmptyError(error)
is NoData.EmptyLoading -> DataState.NoData.EmptyLoading()
is WithData.Data -> DataState.WithData.Data(data)
is WithData.DataAndNewPageInProgress -> DataState.WithData.DataAndNewPageInProgress(data)
is WithData.DataAndRefreshInProgress -> DataState.WithData.DataAndRefreshInProgress(data)
is WithData.FullData -> DataState.WithData.FullData(data)
is WithData.FullDataAndRefreshInProgress -> DataState.WithData.FullDataAndRefreshInProgress(data)
}
}
sealed interface Action<Data, Cursor> {
class InitialLoad<Data, Cursor> : Action<Data, Cursor>
class Refresh<Data, Cursor> : Action<Data, Cursor>
class Restart<Data, Cursor> : Action<Data, Cursor>
class LoadMore<Data, Cursor> : Action<Data, Cursor>
data class InitialDataLoaded<Data, Cursor>(val lastPage: Cursor, val items: List<Data>) : Action<Data, Cursor>
data class InitialDataLoadingError<Data, Cursor>(val error: LoadingError) : Action<Data, Cursor>
data class NewPageLoaded<Data, Cursor>(val lastPage: Cursor, val items: List<Data>) : Action<Data, Cursor>
data class NewPageLoadingError<Data, Cursor>(val error: LoadingError) : Action<Data, Cursor>
}
sealed interface SideEffect<Cursor> {
class NeedToPerformInitialLoad<Cursor> : SideEffect<Cursor>
data class NeedToPerformRefresh<Cursor>(val lastPage: Cursor?) : SideEffect<Cursor>
data class NeedToLoadNewPage<Cursor>(val lastPage: Cursor) : SideEffect<Cursor>
data class NewPageLoadErrorEvent<Cursor>(val error: LoadingError) : SideEffect<Cursor>
}
private fun reduce(
action: Action<Data, Cursor>,
state: State<Data, Cursor>
): State<Data, Cursor> {
fun getIllegalStateException(): IllegalStateException = IllegalStateException("Can't perform action $action in state: $state")
val resultState: State<Data, Cursor> = when (action) {
is Action.InitialLoad -> {
when (state) {
is State.WithData.Data<Data, Cursor>,
is State.WithData.FullData<Data, Cursor>,
is State.WithData.DataAndNewPageInProgress<Data, Cursor>,
is State.WithData.DataAndRefreshInProgress<Data, Cursor>,
is State.WithData.FullDataAndRefreshInProgress<Data, Cursor> -> throw getIllegalStateException()
is State.NoData.Empty,
is State.NoData.EmptyError<Data, Cursor> -> State.NoData.EmptyLoading()
is State.NoData.EmptyLoading<Data, Cursor> -> state
}
}
is Action.InitialDataLoaded<Data, Cursor> -> {
when (state) {
is State.NoData.EmptyError<Data, Cursor>,
is State.WithData.FullData<Data, Cursor>,
is State.WithData.DataAndNewPageInProgress<Data, Cursor>,
is State.WithData.DataAndRefreshInProgress<Data, Cursor>,
is State.WithData.FullDataAndRefreshInProgress<Data, Cursor> -> throw getIllegalStateException()
// data can be loaded several times (from cache and from remote)
is State.WithData.Data<Data, Cursor>,
is State.NoData.Empty<Data, Cursor>,
is State.NoData.EmptyLoading<Data, Cursor> -> {
if (action.items.isEmpty()) {
State.NoData.Empty()
} else {
State.WithData.Data(lastPage = action.lastPage, data = action.items)
}
}
}
}
is Action.InitialDataLoadingError -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.Data<Data, Cursor>,
is State.WithData.FullData<Data, Cursor>,
is State.WithData.DataAndNewPageInProgress<Data, Cursor>,
is State.WithData.DataAndRefreshInProgress<Data, Cursor>,
is State.WithData.FullDataAndRefreshInProgress<Data, Cursor> -> throw getIllegalStateException()
is State.NoData.EmptyLoading -> State.NoData.EmptyError(action.error)
}
}
is Action.Refresh -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError -> State.NoData.EmptyLoading()
is State.NoData.EmptyLoading,
is State.WithData.DataAndRefreshInProgress,
is State.WithData.FullDataAndRefreshInProgress -> state
is State.WithData.Data -> State.WithData.DataAndRefreshInProgress(state.lastPage, state.data)
is State.WithData.DataAndNewPageInProgress -> State.WithData.DataAndRefreshInProgress(state.lastPage, state.data)
is State.WithData.FullData -> State.WithData.FullDataAndRefreshInProgress(state.lastPage, state.data)
}
}
is Action.Restart -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.Data,
is State.WithData.DataAndNewPageInProgress,
is State.WithData.FullData -> State.NoData.EmptyLoading()
is State.NoData.EmptyLoading -> state
is State.WithData.DataAndRefreshInProgress<Data, Cursor>,
is State.WithData.FullDataAndRefreshInProgress<Data, Cursor> -> State.NoData.EmptyLoading()
}
}
is Action.LoadMore -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.FullData,
is State.NoData.EmptyLoading -> throw getIllegalStateException()
is State.WithData.DataAndNewPageInProgress -> state
is State.WithData.DataAndRefreshInProgress -> state
is State.WithData.FullDataAndRefreshInProgress<Data, Cursor> -> state
is State.WithData.Data<Data, Cursor> -> State.WithData.DataAndNewPageInProgress(state.lastPage, state.data)
}
}
is Action.NewPageLoaded<Data, Cursor> -> {
val items = action.items
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.FullData,
is State.NoData.EmptyLoading,
is State.WithData.FullDataAndRefreshInProgress,
is State.WithData.Data -> throw getIllegalStateException()
is State.WithData.DataAndRefreshInProgress -> {
if (items.isEmpty()) {
State.WithData.FullDataAndRefreshInProgress(state.lastPage, state.data)
} else {
State.WithData.DataAndRefreshInProgress(action.lastPage, state.data + items)
}
}
is State.WithData.DataAndNewPageInProgress -> {
if (items.isEmpty()) {
State.WithData.FullData(state.lastPage, state.data)
} else {
State.WithData.Data(action.lastPage, state.data + items)
}
}
}
}
is Action.NewPageLoadingError -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.FullData,
is State.NoData.EmptyLoading,
is State.WithData.FullDataAndRefreshInProgress,
is State.WithData.Data -> throw getIllegalStateException()
is State.WithData.DataAndRefreshInProgress -> State.WithData.DataAndRefreshInProgress(state.lastPage, state.data)
is State.WithData.DataAndNewPageInProgress -> State.WithData.Data(state.lastPage, state.data)
}
}
}
return resultState
}
private fun getSideEffect(
action: Action<Data, Cursor>,
state: State<Data, Cursor>
): SideEffect<Cursor>? {
fun getIllegalStateException(): IllegalStateException = IllegalStateException("Can't perform action $action in state: $state")
return when (action) {
is Action.InitialLoad -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError<Data, Cursor> -> SideEffect.NeedToPerformInitialLoad()
is State.NoData.EmptyLoading,
is State.WithData.Data,
is State.WithData.DataAndNewPageInProgress,
is State.WithData.DataAndRefreshInProgress,
is State.WithData.FullData,
is State.WithData.FullDataAndRefreshInProgress -> null
}
}
is Action.InitialDataLoaded<Data, Cursor> -> null
is Action.InitialDataLoadingError -> null
is Action.Refresh -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError -> SideEffect.NeedToPerformRefresh(null)
is State.NoData.EmptyLoading,
is State.WithData.DataAndRefreshInProgress,
is State.WithData.FullDataAndRefreshInProgress -> null
is State.WithData.Data -> SideEffect.NeedToPerformRefresh(state.lastPage)
is State.WithData.DataAndNewPageInProgress -> SideEffect.NeedToPerformRefresh(state.lastPage)
is State.WithData.FullData -> SideEffect.NeedToPerformRefresh(state.lastPage)
}
}
is Action.Restart -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.Data,
is State.WithData.DataAndNewPageInProgress,
is State.WithData.FullData -> SideEffect.NeedToPerformInitialLoad()
is State.NoData.EmptyLoading,
is State.WithData.DataAndRefreshInProgress<Data, Cursor>,
is State.WithData.FullDataAndRefreshInProgress<Data, Cursor> -> null
}
}
is Action.LoadMore -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.FullData,
is State.NoData.EmptyLoading,
is State.WithData.DataAndNewPageInProgress -> null
is State.WithData.DataAndRefreshInProgress -> SideEffect.NeedToLoadNewPage(state.lastPage)
is State.WithData.FullDataAndRefreshInProgress<Data, Cursor> -> SideEffect.NeedToLoadNewPage(state.lastPage)
is State.WithData.Data<Data, Cursor> -> SideEffect.NeedToLoadNewPage(state.lastPage)
}
}
is Action.NewPageLoaded<Data, Cursor> -> null
is Action.NewPageLoadingError -> {
when (state) {
is State.NoData.Empty,
is State.NoData.EmptyError,
is State.WithData.FullData,
is State.NoData.EmptyLoading,
is State.WithData.FullDataAndRefreshInProgress,
is State.WithData.Data -> null
is State.WithData.DataAndRefreshInProgress -> SideEffect.NewPageLoadErrorEvent(action.error)
is State.WithData.DataAndNewPageInProgress -> SideEffect.NewPageLoadErrorEvent(action.error)
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment