diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomePage.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomePage.kt index 176ad62a..0518c683 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomePage.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomePage.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -81,8 +80,10 @@ import com.huanchengfly.tieba.post.ui.widgets.compose.ActionItem import com.huanchengfly.tieba.post.ui.widgets.compose.Avatar import com.huanchengfly.tieba.post.ui.widgets.compose.Button import com.huanchengfly.tieba.post.ui.widgets.compose.ConfirmDialog +import com.huanchengfly.tieba.post.ui.widgets.compose.ErrorScreen import com.huanchengfly.tieba.post.ui.widgets.compose.LongClickMenu import com.huanchengfly.tieba.post.ui.widgets.compose.TextButton +import com.huanchengfly.tieba.post.ui.widgets.compose.TipScreen import com.huanchengfly.tieba.post.ui.widgets.compose.Toolbar import com.huanchengfly.tieba.post.ui.widgets.compose.accountNavIconIfCompact import com.huanchengfly.tieba.post.ui.widgets.compose.rememberDialogState @@ -153,8 +154,8 @@ fun SearchBox( @Composable private fun Header( text: String, - invert: Boolean = false, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + invert: Boolean = false ) { Chip( text = text, @@ -391,6 +392,11 @@ fun HomePage( prop1 = HomeUiState::topForums, initial = emptyList() ) + val error by viewModel.uiState.collectPartialAsState( + prop1 = HomeUiState::error, + initial = null + ) + val isError by remember { derivedStateOf { error != null } } var listSingle by remember { mutableStateOf(context.appPreferences.listSingle) } val gridCells by remember { derivedStateOf { getGridCells(context, listSingle) } } @@ -421,9 +427,12 @@ fun HomePage( ) { contentPaddings -> StateScreen( isEmpty = forums.isEmpty(), - isError = false, + isError = isError, isLoading = isLoading, modifier = Modifier.padding(contentPaddings), + onReload = { + viewModel.send(HomeUiIntent.Refresh) + }, emptyScreen = { EmptyScreen( loggedIn = account != null, @@ -433,6 +442,9 @@ fun HomePage( }, loadingScreen = { HomePageSkeletonScreen(listSingle = listSingle, gridCells = gridCells) + }, + errorScreen = { + error?.let { ErrorScreen(error = it) } } ) { val pullRefreshState = rememberPullRefreshState( @@ -516,11 +528,11 @@ private fun HomePageSkeletonScreen( Column { Header( text = stringResource(id = R.string.title_top_forum), - invert = true, modifier = Modifier.placeholder( visible = true, color = ExtendedTheme.colors.chip - ) + ), + invert = true ) Spacer(modifier = Modifier.height(8.dp)) } @@ -541,11 +553,11 @@ private fun HomePageSkeletonScreen( Column { Header( text = stringResource(id = R.string.forum_list_title), - invert = true, modifier = Modifier.placeholder( visible = true, color = ExtendedTheme.colors.chip - ) + ), + invert = true ) Spacer(modifier = Modifier.height(8.dp)) } @@ -563,59 +575,55 @@ fun EmptyScreen( onOpenExplore: () -> Unit ) { val context = LocalContext.current - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, alignment = CenterVertically) - ) { - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lottie_astronaut)) - LottieAnimation( - composition = composition, - iterations = LottieConstants.IterateForever, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(2f) - ) - if (!loggedIn) { - Text( - text = stringResource(id = R.string.title_empty_login), - style = MaterialTheme.typography.h6, - color = ExtendedTheme.colors.text, - textAlign = TextAlign.Center, - ) - Text( - text = stringResource(id = R.string.home_empty_login), - style = MaterialTheme.typography.body1, - color = ExtendedTheme.colors.textSecondary, - textAlign = TextAlign.Center - ) - Button( - onClick = { - context.goToActivity() - }, + TipScreen( + title = { + if (!loggedIn) { + Text(text = stringResource(id = R.string.title_empty_login)) + } else { + Text(text = stringResource(id = R.string.title_empty)) + } + }, + image = { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lottie_astronaut)) + LottieAnimation( + composition = composition, + iterations = LottieConstants.IterateForever, modifier = Modifier .fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.button_login)) - } - } else { - Text( - text = stringResource(id = R.string.title_empty), - style = MaterialTheme.typography.h6, - color = ExtendedTheme.colors.text, - textAlign = TextAlign.Center, + .aspectRatio(2f) ) - } - if (canOpenExplore) { - TextButton( - onClick = onOpenExplore, - modifier = Modifier - .fillMaxWidth() - ) { - Text(text = stringResource(id = R.string.button_go_to_explore)) + }, + message = { + if (!loggedIn) { + Text( + text = stringResource(id = R.string.home_empty_login), + style = MaterialTheme.typography.body1, + color = ExtendedTheme.colors.textSecondary, + textAlign = TextAlign.Center + ) } - } - } + }, + actions = { + if (!loggedIn) { + Button( + onClick = { + context.goToActivity() + }, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.button_login)) + } + } + if (canOpenExplore) { + TextButton( + onClick = onOpenExplore, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.button_go_to_explore)) + } + } + }, + ) } \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomeViewModel.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomeViewModel.kt index a9dc2134..5fa8fe77 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/main/home/HomeViewModel.kt @@ -15,24 +15,29 @@ import org.litepal.LitePal class HomeViewModel : BaseViewModel() { override fun createInitialState(): HomeUiState = HomeUiState() - override fun createPartialChangeProducer(): PartialChangeProducer = HomePartialChangeProducer + override fun createPartialChangeProducer(): PartialChangeProducer = + HomePartialChangeProducer override fun dispatchEvent(partialChange: HomePartialChange): UiEvent? = when (partialChange) { - is HomePartialChange.Refresh.Failure -> CommonUiEvent.Toast(partialChange.errorMessage) is HomePartialChange.TopForums.Delete.Failure -> CommonUiEvent.Toast(partialChange.errorMessage) is HomePartialChange.TopForums.Add.Failure -> CommonUiEvent.Toast(partialChange.errorMessage) else -> null } - object HomePartialChangeProducer : PartialChangeProducer { + object HomePartialChangeProducer : + PartialChangeProducer { @OptIn(FlowPreview::class) override fun toPartialChangeFlow(intentFlow: Flow): Flow { return merge( - intentFlow.filterIsInstance().flatMapConcat { produceRefreshPartialChangeFlow() }, - intentFlow.filterIsInstance().flatMapConcat { it.toPartialChangeFlow() }, - intentFlow.filterIsInstance().flatMapConcat { it.toPartialChangeFlow() }, - intentFlow.filterIsInstance().flatMapConcat { it.toPartialChangeFlow() }, + intentFlow.filterIsInstance() + .flatMapConcat { produceRefreshPartialChangeFlow() }, + intentFlow.filterIsInstance() + .flatMapConcat { it.toPartialChangeFlow() }, + intentFlow.filterIsInstance() + .flatMapConcat { it.toPartialChangeFlow() }, + intentFlow.filterIsInstance() + .flatMapConcat { it.toPartialChangeFlow() }, ) } @@ -54,7 +59,7 @@ class HomeViewModel : BaseViewModel { HomePartialChange.Unfollow.Success(forumId) } @@ -93,9 +99,9 @@ sealed interface HomeUiIntent : UiIntent { data class Unfollow(val forumId: String, val forumName: String) : HomeUiIntent sealed interface TopForums : HomeUiIntent { - data class Delete(val forumId: String): TopForums + data class Delete(val forumId: String) : TopForums - data class Add(val forum: HomeUiState.Forum): TopForums + data class Add(val forum: HomeUiState.Forum) : TopForums } } @@ -109,6 +115,7 @@ sealed interface HomePartialChange : PartialChange { topForums = oldState.topForums.filterNot { it.forumId == forumId }, ) } + is Failure -> oldState } @@ -120,8 +127,14 @@ sealed interface HomePartialChange : PartialChange { sealed class Refresh : HomePartialChange { override fun reduce(oldState: HomeUiState): HomeUiState = when (this) { - is Success -> oldState.copy(isLoading = false, forums = forums, topForums = topForums) - is Failure -> oldState.copy(isLoading = false) + is Success -> oldState.copy( + isLoading = false, + forums = forums, + topForums = topForums, + error = null + ) + + is Failure -> oldState.copy(isLoading = false, error = error) Start -> oldState.copy(isLoading = true) } @@ -133,7 +146,7 @@ sealed interface HomePartialChange : PartialChange { ) : Refresh() data class Failure( - val errorMessage: String + val error: Throwable ) : Refresh() } @@ -160,6 +173,7 @@ sealed interface HomePartialChange : PartialChange { topForums = oldState.forums.filter { topForumsId.contains(it.forumId) } ) } + is Failure -> oldState } @@ -174,6 +188,7 @@ data class HomeUiState( val isLoading: Boolean = true, val forums: List = emptyList(), val topForums: List = emptyList(), + val error: Throwable? = null, ) : UiState { data class Forum( val avatar: String, diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Errors.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Errors.kt new file mode 100644 index 00000000..93efe1cf --- /dev/null +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Errors.kt @@ -0,0 +1,219 @@ +package com.huanchengfly.tieba.post.ui.widgets.compose + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import com.huanchengfly.tieba.post.R +import com.huanchengfly.tieba.post.api.retrofit.exception.NoConnectivityException +import com.huanchengfly.tieba.post.api.retrofit.exception.TiebaApiException +import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorCode +import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorMessage +import com.huanchengfly.tieba.post.arch.BaseComposeActivity.Companion.LocalWindowSizeClass +import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme +import com.huanchengfly.tieba.post.ui.common.windowsizeclass.WindowWidthSizeClass +import com.huanchengfly.tieba.post.ui.widgets.compose.states.StateScreenScope + +@Composable +fun TipScreen( + title: @Composable (ColumnScope.() -> Unit), + modifier: Modifier = Modifier, + image: @Composable (ColumnScope.() -> Unit) = {}, + message: @Composable (ColumnScope.() -> Unit) = {}, + actions: @Composable (ColumnScope.() -> Unit) = {} +) { + val widthFraction = + if (LocalWindowSizeClass.current.widthSizeClass == WindowWidthSizeClass.Compact) 0.9f else 0.5f + Column(modifier = modifier) { + Column( + modifier = Modifier + .fillMaxWidth(fraction = widthFraction) + .padding(16.dp) + .verticalScroll( + rememberScrollState() + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically) + ) { + image() + ProvideTextStyle( + value = MaterialTheme.typography.h6.copy( + color = ExtendedTheme.colors.text, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center + ) + ) { + title() + } + ProvideTextStyle( + value = MaterialTheme.typography.body1.copy( + color = ExtendedTheme.colors.textSecondary, + textAlign = TextAlign.Center + ) + ) { + message() + } + actions() + } + } +} + +enum class ErrorType { + NETWORK, + SERVER, + UNKNOWN +} + +@Composable +fun StateScreenScope.ErrorScreen( + error: Throwable, + modifier: Modifier = Modifier, + showReload: Boolean = true, + actions: @Composable (ColumnScope.() -> Unit) = {}, +) { + ErrorTipScreen( + error = error, + modifier = modifier, + actions = { + if (showReload && canReload) { + Button(onClick = { reload() }) { + Text(text = stringResource(id = R.string.btn_reload)) + } + } + + actions() + } + ) +} + +@Composable +fun ErrorTipScreen( + error: Throwable, + modifier: Modifier = Modifier, + actions: @Composable (ColumnScope.() -> Unit) = {}, +) { + val errorType = when (error) { + is NoConnectivityException -> ErrorType.NETWORK + is TiebaApiException -> ErrorType.SERVER + else -> ErrorType.UNKNOWN + } + val errorMessage = error.getErrorMessage() + val errorCode = error.getErrorCode() + ErrorTipScreen( + errorType = errorType, + errorMessage = errorMessage, + modifier = modifier, + errorCode = errorCode, + actions = actions + ) +} + +@Composable +fun ErrorTipScreen( + errorType: ErrorType, + errorMessage: String, + modifier: Modifier = Modifier, + errorCode: Int? = null, + appendMessage: @Composable (ColumnScope.() -> Unit) = {}, + actions: @Composable (ColumnScope.() -> Unit) = {}, +) { + TipScreen( + title = { + when (errorType) { + ErrorType.NETWORK -> { + Text(text = stringResource(id = R.string.title_no_internet_connectivity)) + } + + ErrorType.SERVER -> { + Text(text = stringResource(id = R.string.title_api_error)) + } + + ErrorType.UNKNOWN -> { + Text(text = stringResource(id = R.string.title_unknown_error)) + } + } + }, + modifier = modifier, + image = { + when (errorType) { + ErrorType.NETWORK -> { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lottie_no_internet)) + LottieAnimation( + composition = composition, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f) + ) + } + + ErrorType.SERVER -> { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lottie_error)) + LottieAnimation( + composition = composition, + iterations = LottieConstants.IterateForever, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f) + ) + } + + ErrorType.UNKNOWN -> { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lottie_bug_hunting)) + LottieAnimation( + composition = composition, + iterations = LottieConstants.IterateForever, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f) + ) + } + } + }, + message = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + when (errorType) { + ErrorType.NETWORK -> { + Text( + text = stringResource( + id = R.string.message_no_internet_connectivity, + errorMessage + ) + ) + } + + ErrorType.SERVER -> { + val errorCodeText = "($errorCode)".takeIf { errorCode != null }.orEmpty() + Text(text = "$errorMessage$errorCodeText") + } + + ErrorType.UNKNOWN -> { + Text(text = stringResource(id = R.string.message_unknown_error)) + } + } + appendMessage() + } + }, + actions = actions + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/states/States.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/states/States.kt index 85c52800..2b45eebe 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/states/States.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/states/States.kt @@ -2,29 +2,42 @@ package com.huanchengfly.tieba.post.ui.widgets.compose.states import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material.CircularProgressIndicator +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition import com.huanchengfly.tieba.post.R import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme import com.huanchengfly.tieba.post.ui.widgets.compose.EmptyPlaceholder -val DefaultLoadingScreen: @Composable () -> Unit = { - CircularProgressIndicator(modifier = Modifier.size(48.dp), color = MaterialTheme.colors.primary) +val DefaultLoadingScreen: @Composable StateScreenScope.() -> Unit = { + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.lottie_loading_paperplane)) + LottieAnimation( + composition = composition, + iterations = LottieConstants.IterateForever, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f) + ) +// CircularProgressIndicator(modifier = Modifier.size(48.dp), color = MaterialTheme.colors.primary) } -val DefaultEmptyScreen: @Composable () -> Unit = { +val DefaultEmptyScreen: @Composable StateScreenScope.() -> Unit = { EmptyPlaceholder() } -val DefaultErrorScreen: @Composable () -> Unit = { +val DefaultErrorScreen: @Composable StateScreenScope.() -> Unit = { Text( text = stringResource(id = R.string.error_tip), style = MaterialTheme.typography.body1, @@ -39,12 +52,14 @@ fun StateScreen( isLoading: Boolean, modifier: Modifier = Modifier, onReload: (() -> Unit)? = null, - emptyScreen: @Composable () -> Unit = DefaultEmptyScreen, - errorScreen: @Composable () -> Unit = DefaultErrorScreen, - loadingScreen: @Composable () -> Unit = DefaultLoadingScreen, - content: @Composable () -> Unit, + clickToReload: Boolean = false, + emptyScreen: @Composable StateScreenScope.() -> Unit = DefaultEmptyScreen, + errorScreen: @Composable StateScreenScope.() -> Unit = DefaultErrorScreen, + loadingScreen: @Composable StateScreenScope.() -> Unit = DefaultLoadingScreen, + content: @Composable StateScreenScope.() -> Unit, ) { - val clickableModifier = if (onReload != null) Modifier.clickable( + val stateScreenScope = remember(key1 = onReload) { StateScreenScope(onReload) } + val clickableModifier = if (onReload != null && clickToReload) Modifier.clickable( enabled = isEmpty && !isLoading, onClick = onReload ) else Modifier @@ -56,15 +71,26 @@ fun StateScreen( contentAlignment = Alignment.Center ) { if (!isEmpty) { - content() + stateScreenScope.content() } else { if (isLoading) { - loadingScreen() + stateScreenScope.loadingScreen() } else if (isError) { - errorScreen() + stateScreenScope.errorScreen() } else { - emptyScreen() + stateScreenScope.emptyScreen() } } } +} + +class StateScreenScope( + private val onReload: (() -> Unit)? = null +) { + val canReload: Boolean + get() = onReload != null + + fun reload() { + onReload?.invoke() + } } \ No newline at end of file