diff --git a/core/core/src/commonMain/kotlin/com/stslex/core/core/coroutine/AppCoroutineScope.kt b/core/core/src/commonMain/kotlin/com/stslex/core/core/coroutine/AppCoroutineScope.kt new file mode 100644 index 00000000..c828c174 --- /dev/null +++ b/core/core/src/commonMain/kotlin/com/stslex/core/core/coroutine/AppCoroutineScope.kt @@ -0,0 +1,40 @@ +package com.stslex.core.core.coroutine + +import com.stslex.core.core.AppDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow + +interface AppCoroutineScope { + + /** + * Launches a coroutine and catches exceptions. The coroutine is launched on the default dispatcher of the AppDispatcher. + * @param onError - error handler + * @param onSuccess - success handler + * @param action - action to be executed + * @return Job + * @see Job + * @see AppDispatcher + * */ + fun launch( + onError: suspend (Throwable) -> Unit = {}, + onSuccess: suspend CoroutineScope.(T) -> Unit = {}, + action: suspend CoroutineScope.() -> T, + ): Job + + /** + * Launches a flow and collects it in the screenModelScope. The flow is collected on the default dispatcher. of the AppDispatcher. + * @param onError - error handler + * @param each - action for each element of the flow + * @return Job + * @see Flow + * @see Job + * @see AppDispatcher + * */ + fun launch( + flow: Flow, + onError: suspend (cause: Throwable) -> Unit = {}, + each: suspend (T) -> Unit + ): Job +} + diff --git a/core/core/src/commonMain/kotlin/com/stslex/core/core/coroutine/AppCoroutineScopeImpl.kt b/core/core/src/commonMain/kotlin/com/stslex/core/core/coroutine/AppCoroutineScopeImpl.kt new file mode 100644 index 00000000..499e1507 --- /dev/null +++ b/core/core/src/commonMain/kotlin/com/stslex/core/core/coroutine/AppCoroutineScopeImpl.kt @@ -0,0 +1,47 @@ +package com.stslex.core.core.coroutine + +import com.stslex.core.core.AppDispatcher +import com.stslex.core.core.Logger +import com.stslex.core.core.coroutineExceptionHandler +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +class AppCoroutineScopeImpl( + private val scope: CoroutineScope, + private val appDispatcher: AppDispatcher +) : AppCoroutineScope { + + private fun exceptionHandler( + onError: suspend (cause: Throwable) -> Unit = {}, + ) = CoroutineExceptionHandler { _, throwable -> + Logger.exception(throwable) + scope.launch(appDispatcher.default + coroutineExceptionHandler) { + onError(throwable) + } + } + + override fun launch( + onError: suspend (Throwable) -> Unit, + onSuccess: suspend CoroutineScope.(T) -> Unit, + action: suspend CoroutineScope.() -> T + ): Job = scope.launch( + context = exceptionHandler(onError) + appDispatcher.default, + block = { + onSuccess(action()) + } + ) + + override fun launch( + flow: Flow, + onError: suspend (cause: Throwable) -> Unit, + each: suspend (T) -> Unit + ): Job = scope.launch( + context = exceptionHandler(onError) + appDispatcher.default, + block = { + flow.collect(each) + } + ) +} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/base/Animations.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/base/Animations.kt index 19bbfcba..de8c97fa 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/base/Animations.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/base/Animations.kt @@ -29,42 +29,47 @@ import com.stslex.core.ui.theme.toPx import kotlin.math.roundToInt fun Modifier.shimmerLoadingAnimation( + isShimmerVisible: Boolean = true, widthOfShadowBrush: Int = 500, angleOfAxisY: Float = 270f, durationMillis: Int = 1000, -): Modifier = composed { - val shimmerColors = with(MaterialTheme.colorScheme.surfaceVariant) { - listOf( - copy(alpha = 0.3f), - copy(alpha = 0.5f), - copy(alpha = 0.7f), - copy(alpha = 0.5f), - copy(alpha = 0.3f), - ) - } +): Modifier = if (isShimmerVisible) { + composed { + val shimmerColors = with(MaterialTheme.colorScheme.surfaceVariant) { + listOf( + copy(alpha = 0.3f), + copy(alpha = 0.5f), + copy(alpha = 0.7f), + copy(alpha = 0.5f), + copy(alpha = 0.3f), + ) + } - val transition = rememberInfiniteTransition(label = "shimmer loading animation transition") + val transition = rememberInfiniteTransition(label = "shimmer loading animation transition") - val translateAnimation = transition.animateFloat( - initialValue = 0f, - targetValue = (durationMillis + widthOfShadowBrush).toFloat(), - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = durationMillis, - easing = LinearEasing, + val translateAnimation = transition.animateFloat( + initialValue = 0f, + targetValue = (durationMillis + widthOfShadowBrush).toFloat(), + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = durationMillis, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Restart, ), - repeatMode = RepeatMode.Restart, - ), - label = "Shimmer loading animation", - ) + label = "Shimmer loading animation", + ) - background( - brush = Brush.linearGradient( - colors = shimmerColors, - start = Offset(x = translateAnimation.value - widthOfShadowBrush, y = 0.0f), - end = Offset(x = translateAnimation.value, y = angleOfAxisY), - ), - ) + background( + brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset(x = translateAnimation.value - widthOfShadowBrush, y = 0.0f), + end = Offset(x = translateAnimation.value, y = angleOfAxisY), + ), + ) + } +} else { + this } @Composable diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt index bd7438b3..5bb908e5 100644 --- a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/mvi/BaseStore.kt @@ -4,6 +4,8 @@ import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import com.stslex.core.core.AppDispatcher import com.stslex.core.core.Logger +import com.stslex.core.core.coroutine.AppCoroutineScope +import com.stslex.core.core.coroutine.AppCoroutineScopeImpl import com.stslex.core.core.coroutineExceptionHandler import com.stslex.core.ui.mvi.Store.Action import com.stslex.core.ui.mvi.Store.Event @@ -29,6 +31,11 @@ abstract class BaseStore( protected val lastAction: A? get() = _lastAction + protected val scope: AppCoroutineScope = AppCoroutineScopeImpl( + scope = screenModelScope, + appDispatcher = appDispatcher + ) + /** * Sends an action to the store. Checks if the action is not the same as the last action. * If the action is not the same as the last action, the last action is updated. @@ -104,11 +111,10 @@ abstract class BaseStore( onError: suspend (Throwable) -> Unit = {}, onSuccess: suspend CoroutineScope.(T) -> Unit = {}, action: suspend CoroutineScope.() -> T, - ): Job = screenModelScope.launch( - context = exceptionHandler(onError) + appDispatcher.default, - block = { - onSuccess(action()) - } + ): Job = scope.launch( + onError = onError, + onSuccess = onSuccess, + action = action ) /** @@ -120,13 +126,12 @@ abstract class BaseStore( * @see Job * @see AppDispatcher * */ - protected fun Flow.launchFlow( + protected fun Flow.launch( onError: suspend (cause: Throwable) -> Unit = {}, each: suspend (T) -> Unit - ): Job = screenModelScope.launch( - context = exceptionHandler(onError) + appDispatcher.default, - block = { - collect(each) - } + ): Job = scope.launch( + flow = this, + onError = onError, + each = each, ) } \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagerLoadEvents.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagerLoadEvents.kt new file mode 100644 index 00000000..1382c0c9 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagerLoadEvents.kt @@ -0,0 +1,8 @@ +package com.stslex.core.ui.pager + +import com.stslex.core.ui.base.AppError + +sealed interface PagerLoadEvents { + + data class Error(val error: AppError) : PagerLoadEvents +} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagerLoadState.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagerLoadState.kt new file mode 100644 index 00000000..3bdacd36 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagerLoadState.kt @@ -0,0 +1,20 @@ +package com.stslex.core.ui.pager + +import com.stslex.core.ui.base.AppError + +sealed interface PagerLoadState { + + data object Data : PagerLoadState + + data object Initial : PagerLoadState + + data object Loading : PagerLoadState + + data object Refresh : PagerLoadState + + data object Retry : PagerLoadState + + data class Error(val error: AppError) : PagerLoadState + + data object Empty : PagerLoadState +} diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagingMapper.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagingMapper.kt new file mode 100644 index 00000000..bed3780d --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/PagingMapper.kt @@ -0,0 +1,9 @@ +package com.stslex.core.ui.pager + +import com.stslex.core.core.paging.PagingCoreItem +import com.stslex.core.ui.base.paging.PagingItem + +fun interface PagingMapper { + + suspend operator fun invoke(item: T): R +} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/StorePager.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/StorePager.kt new file mode 100644 index 00000000..d99b07c5 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/StorePager.kt @@ -0,0 +1,23 @@ +package com.stslex.core.ui.pager + +import com.stslex.core.ui.base.paging.PagingItem +import com.stslex.core.ui.base.paging.PagingState +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +interface StorePager { + + val state: StateFlow> + + val loadState: StateFlow + + val loadEvents: SharedFlow + + fun initialLoad() + + fun load() + + fun refresh() + + fun retry() +} \ No newline at end of file diff --git a/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/StorePagerImpl.kt b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/StorePagerImpl.kt new file mode 100644 index 00000000..eaccb711 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/com/stslex/core/ui/pager/StorePagerImpl.kt @@ -0,0 +1,128 @@ +package com.stslex.core.ui.pager + +import com.stslex.core.core.coroutine.AppCoroutineScope +import com.stslex.core.core.paging.PagingCoreData +import com.stslex.core.core.paging.PagingCoreData.Companion.DEFAULT_PAGE +import com.stslex.core.core.paging.PagingCoreItem +import com.stslex.core.core.paging.PagingResponse +import com.stslex.core.ui.base.mapToAppError +import com.stslex.core.ui.base.paging.PagingItem +import com.stslex.core.ui.base.paging.PagingState +import com.stslex.core.ui.base.paging.pagingMap +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +class StorePagerImpl( + private val request: suspend (page: Int, pageSize: Int) -> PagingResponse, + private val scope: AppCoroutineScope, + private val mapper: PagingMapper, + pageSize: Int = PagingCoreData.DEFAULT_PAGE_SIZE, +) : StorePager { + + private val _state = MutableStateFlow>(PagingState.default(pageSize)) + override val state = _state.asStateFlow() + + private val _loadState = MutableStateFlow(PagerLoadState.Initial) + override val loadState = _loadState.asStateFlow() + + private val _loadEvents = MutableSharedFlow() + override val loadEvents = _loadEvents.asSharedFlow() + + private var loadJob: Job? = null + + override fun initialLoad() { + _state.update { currentState -> + currentState.copy( + hasMore = true + ) + } + if (state.value.result.isEmpty()) { + requestItems(isForceLoad = true) + } + } + + override fun load() { + if ( + state.value.hasMore.not() || + loadState.value !is PagerLoadState.Data + ) { + return + } + _loadState.value = PagerLoadState.Loading + requestItems(isForceLoad = false) + } + + override fun refresh() { + _loadState.value = PagerLoadState.Refresh + _state.update { currentState -> + currentState.copy( + page = DEFAULT_PAGE + ) + } + requestItems(isForceLoad = true) + } + + override fun retry() { + if ( + loadState.value !is PagerLoadState.Error || + loadState.value is PagerLoadState.Initial + ) { + return + } + _loadState.value = PagerLoadState.Retry + requestItems(isForceLoad = false) + } + + private fun requestItems( + isForceLoad: Boolean + ) { + if (loadJob?.isActive == true && isForceLoad.not()) { + return + } + loadJob?.cancel() + loadJob = scope.launch( + action = { + val page = state.value.page + val pageSize = state.value.pageSize + request(page, pageSize) + }, + onSuccess = { result -> + val newPagingState = result.pagingMap(mapper::invoke) + if ( + newPagingState.result.isEmpty() && + (state.value.page == DEFAULT_PAGE || state.value.result.isEmpty()) + ) { + _state.value = newPagingState + _loadState.value = PagerLoadState.Empty + return@launch + } + val newItems = if (state.value.page == DEFAULT_PAGE) { + newPagingState.result + } else { + (state.value.result + newPagingState.result).toImmutableList() + } + _state.value = newPagingState.copy( + result = newItems + ) + _loadState.value = PagerLoadState.Data + }, + onError = { error -> + val appError = error.mapToAppError("error load matches") + if ( + loadState.value is PagerLoadState.Data || + loadState.value is PagerLoadState.Loading || + loadState.value is PagerLoadState.Refresh + ) { + _loadEvents.emit(PagerLoadEvents.Error(appError)) + } else { + _loadState.value = PagerLoadState.Error(appError) + } + } + ) + } +} \ No newline at end of file diff --git a/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/ui/store/FavouriteStore.kt b/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/ui/store/FavouriteStore.kt index 700352ff..043ccee2 100644 --- a/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/ui/store/FavouriteStore.kt +++ b/feature/favourite/src/commonMain/kotlin/com/stslex/feature/favourite/ui/store/FavouriteStore.kt @@ -83,7 +83,7 @@ class FavouriteStore( state.map { it.query } .distinctUntilChanged() - .launchFlow { query -> + .launch { query -> updateState { state -> state.copy( page = DEFAULT_PAGE, @@ -94,7 +94,7 @@ class FavouriteStore( } interactor.favourites - .launchFlow { data -> + .launch { data -> val screen = if (data.isEmpty()) { FavouriteScreenState.Content.Empty } else { diff --git a/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/store/FilmStore.kt b/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/store/FilmStore.kt index c8204b37..a64608bf 100644 --- a/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/store/FilmStore.kt +++ b/feature/film/src/commonMain/kotlin/com/stslex/feature/film/ui/store/FilmStore.kt @@ -73,7 +73,7 @@ class FilmStore( } interactor .getFilm(action.id) - .launchFlow { film -> + .launch { film -> updateState { currentState -> currentState.copy( screenState = FilmScreenState.Content(film.toUi()) diff --git a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt index 7ccff878..d48c3743 100644 --- a/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt +++ b/feature/follower/src/commonMain/kotlin/com.stslex.feature.follower/ui/store/FollowerStore.kt @@ -49,7 +49,7 @@ class FollowerStore( state.map { it.query } .distinctUntilChanged() - .launchFlow { query -> + .launch { query -> updateState { state -> state.copy( page = DEFAULT_PAGE, @@ -60,7 +60,7 @@ class FollowerStore( } interactor.followItems - .launchFlow { data -> + .launch { data -> val screen = if (data.isEmpty()) { FollowerScreenState.Empty } else { diff --git a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenContent.kt b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenContent.kt index 22b9d157..171203ad 100644 --- a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenContent.kt +++ b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenContent.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.stslex.core.ui.base.DotsPrintAnimation import com.stslex.core.ui.base.paging.PagingState +import com.stslex.core.ui.base.shimmerLoadingAnimation import com.stslex.core.ui.theme.AppDimension import com.stslex.feature.match.ui.model.MatchUiModel import com.stslex.feature.match.ui.store.MatchScreenState @@ -54,9 +55,6 @@ internal fun MatchScreenContent( } } - Column { - - } Box( modifier = modifier.fillMaxSize(), ) { @@ -64,9 +62,6 @@ internal fun MatchScreenContent( modifier = Modifier.fillMaxSize(), state = lazyListState, ) { - item { - Text("total count: ${state.total}, result size: ${state.result.size}") - } items( count = state.result.size, key = { index -> @@ -85,6 +80,9 @@ internal fun MatchScreenContent( if (screen is MatchScreenState.Content.Append) { item { DotsPrintAnimation( + modifier = Modifier + .fillMaxWidth() + .padding(AppDimension.Padding.medium), dotsCount = 3, ) } @@ -99,27 +97,33 @@ internal fun MatchScreenContent( } @Composable -private fun MatchItem( +internal fun MatchItem( item: MatchUiModel, onItemClicked: (matchUuid: String) -> Unit, modifier: Modifier = Modifier, + isShimmer: Boolean = false, ) { ElevatedCard( - modifier = modifier.fillMaxWidth().padding(AppDimension.Padding.medium), + modifier = modifier + .fillMaxWidth() + .padding(AppDimension.Padding.medium) + .shimmerLoadingAnimation(isShimmer), onClick = { onItemClicked(item.uuid) }, ) { Column { Text( text = item.title, modifier = Modifier - .padding(AppDimension.Padding.medium), + .padding(AppDimension.Padding.medium) + .shimmerLoadingAnimation(isShimmer), style = MaterialTheme.typography.titleLarge, ) Spacer(modifier = Modifier.height(AppDimension.Padding.medium)) Text( text = item.title, modifier = Modifier - .padding(AppDimension.Padding.medium), + .padding(AppDimension.Padding.medium) + .shimmerLoadingAnimation(isShimmer), style = MaterialTheme.typography.bodyMedium, ) } diff --git a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenShimmer.kt b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenShimmer.kt index bcd711ef..1dc6c250 100644 --- a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenShimmer.kt +++ b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/components/MatchScreenShimmer.kt @@ -2,10 +2,12 @@ package com.stslex.feature.match.ui.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import com.stslex.feature.match.ui.model.MatchUiModel @Composable internal fun MatchScreenShimmer( @@ -15,6 +17,19 @@ internal fun MatchScreenShimmer( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items( + count = 15 + ) { + MatchItem( + isShimmer = true, + onItemClicked = { }, + item = MatchUiModel.EMPTY + ) + } + } Text(text = "Shimmer") } } \ No newline at end of file diff --git a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchDataMapper.kt b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchDataMapper.kt index 3c40734b..bc0e4c7a 100644 --- a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchDataMapper.kt +++ b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchDataMapper.kt @@ -1,9 +1,11 @@ package com.stslex.feature.match.ui.model import com.stslex.core.core.asyncMap +import com.stslex.core.ui.pager.PagerLoadState import com.stslex.feature.match.domain.model.MatchDomainModel import com.stslex.feature.match.domain.model.MatchDomainStatus import com.stslex.feature.match.domain.model.MatchUserDomainModel +import com.stslex.feature.match.ui.store.MatchScreenState import kotlinx.collections.immutable.toImmutableList internal suspend fun MatchDomainModel.toUi() = MatchUiModel( @@ -34,4 +36,14 @@ private fun MatchUserDomainModel.toUi() = MatchUserUiModel( username = username, isCreator = isCreator, isAccepted = isAccepted -) \ No newline at end of file +) + +fun PagerLoadState.toUi() = when (this) { + PagerLoadState.Data -> MatchScreenState.Content.Data + PagerLoadState.Empty -> MatchScreenState.Empty + PagerLoadState.Initial -> MatchScreenState.Shimmer + PagerLoadState.Loading -> MatchScreenState.Content.Append + PagerLoadState.Refresh -> MatchScreenState.Content.Refresh + PagerLoadState.Retry -> MatchScreenState.Shimmer + is PagerLoadState.Error -> MatchScreenState.Error(error) +} \ No newline at end of file diff --git a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchUiModel.kt b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchUiModel.kt index 6bbda61a..8784cf4f 100644 --- a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchUiModel.kt +++ b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/model/MatchUiModel.kt @@ -3,6 +3,7 @@ package com.stslex.feature.match.ui.model import androidx.compose.runtime.Stable import com.stslex.core.ui.base.paging.PagingItem import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Stable data class MatchUiModel( @@ -15,4 +16,20 @@ data class MatchUiModel( val expiresAtDays: Int, val expiresAtHours: Int, val expiresAtMinutes: Int, -) : PagingItem +) : PagingItem { + + companion object { + + val EMPTY = MatchUiModel( + uuid = "", + title = "", + description = "", + status = MatchUiStatusModel.PENDING, + participants = persistentListOf(), + isCreator = false, + expiresAtDays = 0, + expiresAtHours = 0, + expiresAtMinutes = 0, + ) + } +} diff --git a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStore.kt b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStore.kt index c2f7bd5c..0cc39dfb 100644 --- a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStore.kt +++ b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStore.kt @@ -1,22 +1,24 @@ package com.stslex.feature.match.ui.store import com.stslex.core.core.AppDispatcher -import com.stslex.core.core.paging.PagingCoreData.Companion.DEFAULT_PAGE import com.stslex.core.database.store.UserStore import com.stslex.core.ui.base.mapToAppError -import com.stslex.core.ui.base.paging.PagingState -import com.stslex.core.ui.base.paging.pagingMap import com.stslex.core.ui.mvi.BaseStore import com.stslex.core.ui.mvi.Store.Event.Snackbar +import com.stslex.core.ui.pager.StorePager +import com.stslex.core.ui.pager.StorePagerImpl import com.stslex.feature.match.domain.interactor.MatchInteractor import com.stslex.feature.match.navigation.MatchRouter +import com.stslex.feature.match.ui.model.MatchUiModel import com.stslex.feature.match.ui.model.toUi import com.stslex.feature.match.ui.store.MatchStoreComponent.Action import com.stslex.feature.match.ui.store.MatchStoreComponent.Event import com.stslex.feature.match.ui.store.MatchStoreComponent.Navigation import com.stslex.feature.match.ui.store.MatchStoreComponent.State -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Job + +// todo refactor pager state for UI https://github.com/stslex/Wizard/issues/35 +// todo add base store pager binding https://github.com/stslex/Wizard/issues/36 +// todo add query support for pager https://github.com/stslex/Wizard/issues/37 class MatchStore( appDispatcher: AppDispatcher, @@ -29,7 +31,17 @@ class MatchStore( initialState = State.INITIAL ) { - private var loadJob: Job? = null + private val pager: StorePager = StorePagerImpl( + request = { page, pageSize -> + interactor.getMatches( + uuid = state.value.uuid, + page = page, + pageSize = pageSize + ) + }, + scope = scope, + mapper = { it.toUi() } + ) override fun process(action: Action) { when (action) { @@ -44,31 +56,37 @@ class MatchStore( } private fun actionInit(action: Action.Init) { - val uuid = action.args.uuid ?: userStore.uuid + pager.state.launch { pagerState -> + updateState { currentState -> + currentState.copy( + pagingState = pagerState + ) + } + } + pager.loadState.launch { loadState -> + updateState { currentState -> + currentState.copy( + screen = loadState.toUi() + ) + } + } + pager.loadEvents.launch { + sendEvent( + Event.ShowSnackbar(Snackbar.Error("error load matches")) + ) + } + pager.initialLoad() updateState { currentState -> currentState.copy( isSelf = action.args.isSelf, - uuid = uuid, - screen = MatchScreenState.Shimmer, - pagingState = PagingState.default() + uuid = action.args.uuid ?: userStore.uuid, ) } - loadItems(isForceLoad = true) + pager.initialLoad() } private fun actionLoadMore() { - if ( - state.value.pagingState.hasMore.not() || - state.value.screen !is MatchScreenState.Content.Data - ) { - return - } - updateState { currentState -> - currentState.copy( - screen = MatchScreenState.Content.Append - ) - } - loadItems(isForceLoad = false) + pager.load() } private fun actionOnMatchClick(action: Action.OnMatchClick) { @@ -76,88 +94,11 @@ class MatchStore( } private fun actionRetryClick() { - if ( - state.value.screen !is MatchScreenState.Error || - state.value.screen is MatchScreenState.Shimmer - ) { - return - } - updateState { currentState -> - currentState.copy( - screen = MatchScreenState.Shimmer - ) - } - loadItems(isForceLoad = false) + pager.retry() } private fun actionRefresh() { - updateState { currentState -> - currentState.copy( - screen = MatchScreenState.Content.Refresh, - pagingState = currentState.pagingState.copy( - page = DEFAULT_PAGE - ) - ) - } - loadItems(isForceLoad = true) - } - - private fun loadItems(isForceLoad: Boolean) { - if (loadJob?.isActive == true && isForceLoad.not()) { - return - } - loadJob?.cancel() - loadJob = launch( - action = { - interactor.getMatches( - uuid = state.value.uuid, - page = state.value.pagingState.page, - pageSize = state.value.pagingState.pageSize, - ) - }, - onSuccess = { result -> - val newPagingState = result.pagingMap { it.toUi() } - if ( - newPagingState.result.isEmpty() && - (state.value.pagingState.page == DEFAULT_PAGE || state.value.pagingState.result.isEmpty()) - ) { - updateState { currentState -> - currentState.copy( - screen = MatchScreenState.Empty, - pagingState = newPagingState - ) - } - return@launch - } - val newItems = if (state.value.pagingState.page == DEFAULT_PAGE) { - newPagingState.result - } else { - (state.value.pagingState.result + newPagingState.result).toImmutableList() - } - updateState { currentState -> - currentState.copy( - screen = MatchScreenState.Content.Data, - pagingState = newPagingState.copy( - result = newItems - ) - ) - } - }, - onError = { error -> - val appError = error.mapToAppError("error load matches") - if (state.value.screen is MatchScreenState.Content) { - sendEvent( - Event.ShowSnackbar(Snackbar.Error(appError.message)) - ) - } else { - updateState { currentState -> - currentState.copy( - screen = MatchScreenState.Error(appError) - ) - } - } - } - ) + pager.refresh() } private fun actionLogout() { diff --git a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStoreComponent.kt b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStoreComponent.kt index f91a1bd7..812bacc1 100644 --- a/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStoreComponent.kt +++ b/feature/match/src/commonMain/kotlin/com/stslex/feature/match/ui/store/MatchStoreComponent.kt @@ -7,6 +7,7 @@ import com.stslex.core.ui.mvi.Store.Event.Snackbar import com.stslex.core.ui.navigation.args.MatchScreenArgs import com.stslex.feature.match.ui.model.MatchUiModel +// todo make more representative logic with store components implementation https://github.com/stslex/Wizard/issues/34 interface MatchStoreComponent : Store { @Stable diff --git a/feature/match_feed/src/commonMain/kotlin/com/stslex/feature/match_feed/ui/store/MatchFeedStore.kt b/feature/match_feed/src/commonMain/kotlin/com/stslex/feature/match_feed/ui/store/MatchFeedStore.kt index 582908b0..6f4f4778 100644 --- a/feature/match_feed/src/commonMain/kotlin/com/stslex/feature/match_feed/ui/store/MatchFeedStore.kt +++ b/feature/match_feed/src/commonMain/kotlin/com/stslex/feature/match_feed/ui/store/MatchFeedStore.kt @@ -44,7 +44,7 @@ class MatchFeedStore( private fun actionInit() { interactor .getLatestMatch() - .launchFlow { match -> + .launch { match -> updateState { currentState -> currentState.copy( screen = ScreenState.Content.Success, diff --git a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt index bb366a4a..f113a2bd 100644 --- a/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt +++ b/feature/profile/src/commonMain/kotlin/com/stslex/feature/profile/ui/store/ProfileStore.kt @@ -49,7 +49,7 @@ class ProfileStore( } interactor.getProfile(uuid) - .launchFlow( + .launch( onError = { error -> updateState { currentState -> currentState.copy(