diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorDialogSpec.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorDialogSpec.kt new file mode 100644 index 00000000..a6cefa89 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorDialogSpec.kt @@ -0,0 +1,7 @@ +package com.ninecraft.booket.core.common.constants + +data class ErrorDialogSpec( + val message: String, + val buttonLabel: String, + val action: () -> Unit, +) diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorScope.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorScope.kt new file mode 100644 index 00000000..9fa2c258 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/ErrorScope.kt @@ -0,0 +1,5 @@ +package com.ninecraft.booket.core.common.constants + +enum class ErrorScope { + GLOBAL, LOGIN, BOOK_REGISTER, RECORD_REGISTER +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/event/ErrorEventHelper.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/event/ErrorEventHelper.kt new file mode 100644 index 00000000..2daee82e --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/event/ErrorEventHelper.kt @@ -0,0 +1,22 @@ +package com.ninecraft.booket.core.common.event + +import com.ninecraft.booket.core.common.constants.ErrorDialogSpec +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import java.util.UUID + +object ErrorEventHelper { + private val _errorEvent = Channel(Channel.BUFFERED) + val errorEvent = _errorEvent.receiveAsFlow() + + fun sendError(event: ErrorEvent) { + _errorEvent.trySend(event) + } +} + +sealed interface ErrorEvent { + data class ShowDialog( + val spec: ErrorDialogSpec, + val key: String = UUID.randomUUID().toString(), + ) : ErrorEvent +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt index 57631840..0920c555 100644 --- a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/utils/HandleException.kt @@ -1,5 +1,9 @@ package com.ninecraft.booket.core.common.utils +import com.ninecraft.booket.core.common.constants.ErrorDialogSpec +import com.ninecraft.booket.core.common.constants.ErrorScope +import com.ninecraft.booket.core.common.event.ErrorEvent +import com.ninecraft.booket.core.common.event.ErrorEventHelper import com.ninecraft.booket.core.network.response.ErrorResponse import com.orhanobut.logger.Logger import kotlinx.serialization.SerializationException @@ -39,6 +43,58 @@ fun handleException( } } +fun postErrorDialog( + errorScope: ErrorScope, + exception: Throwable, + action: () -> Unit = {}, +) { + val spec = buildDialog( + scope = errorScope, + exception = exception, + action = action, + ) + + ErrorEventHelper.sendError(event = ErrorEvent.ShowDialog(spec)) +} + +private fun buildDialog( + scope: ErrorScope, + exception: Throwable, + action: () -> Unit, +): ErrorDialogSpec { + val message = when { + exception.isNetworkError() -> { + "네트워크 연결이 불안정합니다.\n인터넷 연결을 확인해주세요" + } + + exception is HttpException -> { + when (scope) { + ErrorScope.GLOBAL -> { + "알 수 없는 문제가 발생했어요.\n다시 시도해주세요" + } + + ErrorScope.LOGIN -> { + "예기치 않은 오류가 발생했습니다.\n다시 로그인 해주세요." + } + + ErrorScope.BOOK_REGISTER -> { + "도서 등록 중 오류가 발생했어요.\n다시 시도해주세요" + } + + ErrorScope.RECORD_REGISTER -> { + "기록 저장에 실패했어요.\n다시 시도해주세요" + } + } + } + + else -> { + "알 수 없는 문제가 발생했어요.\n다시 시도해주세요" + } + } + + return ErrorDialogSpec(message = message, buttonLabel = "확인", action = action) +} + @Suppress("TooGenericExceptionCaught") private fun HttpException.parseErrorMessage(): String? { return try { @@ -69,7 +125,7 @@ private fun getHttpErrorMessage(statusCode: Int): String { } } -private fun Throwable.isNetworkError(): Boolean { +fun Throwable.isNetworkError(): Boolean { return this is UnknownHostException || this is ConnectException || this is SocketTimeoutException || diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 80bab988..7a80a06e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -12,6 +12,7 @@ android { dependencies { implementations( projects.core.designsystem, + projects.core.common, libs.compose.keyboard.state, libs.logger, diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt index 842d7077..c24ee2a6 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedDialog.kt @@ -24,10 +24,10 @@ import com.ninecraft.booket.core.designsystem.theme.ReedTheme @Composable fun ReedDialog( - title: String, confirmButtonText: String, onConfirmRequest: () -> Unit, modifier: Modifier = Modifier, + title: String? = null, description: String? = null, dismissButtonText: String? = null, onDismissRequest: () -> Unit = {}, @@ -63,14 +63,16 @@ fun ReedDialog( it() Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) } - Text( - text = title, - color = ReedTheme.colors.contentPrimary, - textAlign = TextAlign.Center, - style = ReedTheme.typography.headline1SemiBold, - ) - description?.let { + title?.let { + Text( + text = title, + color = ReedTheme.colors.contentPrimary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.headline1SemiBold, + ) Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + } + description?.let { Text( text = description, color = ReedTheme.colors.contentSecondary, diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedErrorUi.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedErrorUi.kt new file mode 100644 index 00000000..8f0b96df --- /dev/null +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/ReedErrorUi.kt @@ -0,0 +1,66 @@ +package com.ninecraft.booket.core.ui.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.ninecraft.booket.core.common.utils.isNetworkError +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.component.button.ReedButton +import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle +import com.ninecraft.booket.core.designsystem.component.button.mediumButtonStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.R + +@Composable +fun ReedErrorUi( + exception: Throwable, + onRetryClick: () -> Unit, +) { + val message = if (exception.isNetworkError()) stringResource(R.string.network_error_message) else stringResource(R.string.server_error_message) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = message, + color = ReedTheme.colors.contentSecondary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + ReedButton( + onClick = { onRetryClick() }, + text = stringResource(R.string.retry), + colorStyle = ReedButtonColorStyle.PRIMARY, + sizeStyle = mediumButtonStyle, + ) + } + } +} + +@ComponentPreview +@Composable +private fun ReedNetworkErrorUiPreview() { + ReedErrorUi( + exception = java.io.IOException("네트워크 오류"), + onRetryClick = {}, + ) +} + +@ComponentPreview +@Composable +private fun ReedServerErrorUiPreview() { + ReedErrorUi( + exception = Exception("알 수 없는 문제"), + onRetryClick = {}, + ) +} diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 4b4bc61a..67994c9b 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ 더 이상 결과가 없습니다 다시 시도 + 네트워크 연결이 불안정합니다.\n인터넷 연결을 확인해주세요 + 알 수 없는 문제가 발생했어요.\n다시 시도해주세요 diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt index 748bc848..d8163766 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt @@ -8,7 +8,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.constants.BookStatus +import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.data.api.repository.RecordRepository import com.ninecraft.booket.core.model.BookDetailModel @@ -32,6 +34,9 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import java.time.LocalDateTime @@ -69,50 +74,52 @@ class BookDetailPresenter @AssistedInject constructor( var isRecordSortBottomSheetVisible by rememberRetained { mutableStateOf(false) } var sideEffect by rememberRetained { mutableStateOf(null) } - fun getSeedsStats() { - scope.launch { - bookRepository.getSeedsStats(screen.userBookId) - .onSuccess { result -> - seedsStates = result.categories.toImmutableList() - } - .onFailure { exception -> - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = BookDetailSideEffect.ShowToast(message) - } + @Suppress("TooGenericExceptionCaught") + suspend fun initialLoad() { + uiState = UiState.Loading - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) + try { + coroutineScope { + val bookDetailDef = async { bookRepository.getBookDetail(screen.isbn13).getOrThrow() } + val seedsDef = async { bookRepository.getSeedsStats(screen.userBookId).getOrThrow() } + val readingRecordsDef = async { + recordRepository.getReadingRecords( + userBookId = screen.userBookId, + sort = currentRecordSort.value, + page = START_INDEX, + size = PAGE_SIZE, + ).getOrThrow() } - } - } + val detail = bookDetailDef.await() + val seeds = seedsDef.await() + val records = readingRecordsDef.await() - fun getBookDetail() { - scope.launch { - bookRepository.getBookDetail(screen.isbn13) - .onSuccess { result -> - bookDetail = result - currentBookStatus = BookStatus.fromValue(result.userBookStatus) ?: BookStatus.BEFORE_READING - } - .onFailure { exception -> - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = BookDetailSideEffect.ShowToast(message) - } + bookDetail = detail + seedsStates = seeds.categories.toImmutableList() + readingRecords = records.content.toPersistentList() - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) - } + isLastPage = records.content.size < PAGE_SIZE + currentStartIndex = START_INDEX + + uiState = UiState.Success + } + } catch (ce: CancellationException) { + throw ce + } catch (e: Throwable) { + uiState = UiState.Error(e) + + val handleErrorMessage = { message: String -> + Logger.e(message) + sideEffect = BookDetailSideEffect.ShowToast(message) + } + + handleException( + exception = e, + onError = handleErrorMessage, + onLoginRequired = { + navigator.resetRoot(LoginScreen) + }, + ) } } @@ -125,6 +132,11 @@ class BookDetailPresenter @AssistedInject constructor( isBookUpdateBottomSheetVisible = false } .onFailure { exception -> + postErrorDialog( + errorScope = ErrorScope.BOOK_REGISTER, + exception = exception, + ) + val handleErrorMessage = { message: String -> Logger.e(message) sideEffect = BookDetailSideEffect.ShowToast(message) @@ -141,13 +153,14 @@ class BookDetailPresenter @AssistedInject constructor( } } - fun getReadingRecords(startIndex: Int = START_INDEX) { + fun loadMoreReadingRecords(startIndex: Int) { + // 초기 페이지 로드는 initialLoad()에서 담당하므로 무시 + if (startIndex == START_INDEX || isLastPage) { + return + } + scope.launch { - if (startIndex == START_INDEX) { - uiState = UiState.Loading - } else { - footerState = FooterState.Loading - } + footerState = FooterState.Loading recordRepository.getReadingRecords( userBookId = screen.userBookId, @@ -155,36 +168,20 @@ class BookDetailPresenter @AssistedInject constructor( page = startIndex, size = PAGE_SIZE, ).onSuccess { result -> - readingRecords = if (startIndex == START_INDEX) { - result.content.toPersistentList() - } else { - (readingRecords + result.content).toPersistentList() - } - + readingRecords = (readingRecords + result.content).toPersistentList() currentStartIndex = startIndex isLastPage = result.content.size < PAGE_SIZE - - if (startIndex == START_INDEX) { - uiState = UiState.Success - } else { - footerState = if (isLastPage) FooterState.End else FooterState.Idle - } + footerState = if (isLastPage) FooterState.End else FooterState.Idle }.onFailure { exception -> Logger.d(exception) val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." - if (startIndex == START_INDEX) { - uiState = UiState.Error(errorMessage) - } else { - footerState = FooterState.Error(errorMessage) - } + footerState = FooterState.Error(errorMessage) } } } LaunchedEffect(Unit) { - getSeedsStats() - getBookDetail() - getReadingRecords() + initialLoad() } fun handleEvent(event: BookDetailUiEvent) { @@ -202,7 +199,7 @@ class BookDetailPresenter @AssistedInject constructor( } is BookDetailUiEvent.OnRegisterRecordButtonClick -> { - navigator.goTo(RecordScreen("")) + navigator.goTo(RecordScreen(screen.userBookId)) } is BookDetailUiEvent.OnRecordSortButtonClick -> { @@ -237,7 +234,13 @@ class BookDetailPresenter @AssistedInject constructor( is BookDetailUiEvent.OnLoadMore -> { if (uiState != UiState.Loading && footerState !is FooterState.Loading && !isLastPage) { - getReadingRecords(startIndex = currentStartIndex + 1) + loadMoreReadingRecords(startIndex = currentStartIndex + 1) + } + } + + is BookDetailUiEvent.OnRetryClick -> { + scope.launch { + initialLoad() } } } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt index d19ca7c2..16ea3a20 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -40,6 +41,7 @@ import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar +import com.ninecraft.booket.core.ui.component.ReedErrorUi import com.ninecraft.booket.feature.detail.R import com.ninecraft.booket.feature.detail.book.component.BookItem import com.ninecraft.booket.feature.detail.book.component.BookUpdateBottomSheet @@ -130,140 +132,161 @@ internal fun BookDetailContent( modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), ) { - InfinityLazyColumn( - modifier = modifier - .fillMaxSize() - .padding(innerPadding), - state = lazyListState, - loadMore = { - state.eventSink(BookDetailUiEvent.OnLoadMore) - }, - ) { - item { - ReedBackTopAppBar( - title = "", - onBackClick = { - state.eventSink(BookDetailUiEvent.OnBackClick) - }, - ) + when (state.uiState) { + is UiState.Idle -> {} + is UiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } } - item { - Column { - BookItem(bookDetail = state.bookDetail) - Spacer(Modifier.height(28.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = ReedTheme.spacing.spacing5), - ) { - ReedButton( - onClick = { - state.eventSink(BookDetailUiEvent.OnBookStatusButtonClick) - }, - text = stringResource( - BookStatus.fromValue(state.bookDetail.userBookStatus)?.getDisplayNameRes() - ?: BookStatus.BEFORE_READING.getDisplayNameRes(), - ), - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.SECONDARY, - modifier = Modifier.widthIn(min = 98.dp), - trailingIcon = { - Icon( - imageVector = ImageVector.vectorResource(designR.drawable.ic_chevron_down), - contentDescription = "Dropdown Icon", - modifier = Modifier.size(22.dp), - tint = ReedTheme.colors.contentPrimary, - ) - }, - ) - Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) - ReedButton( - onClick = { - state.eventSink(BookDetailUiEvent.OnRegisterRecordButtonClick) + is UiState.Success -> { + InfinityLazyColumn( + modifier = modifier + .fillMaxSize() + .padding(innerPadding), + state = lazyListState, + loadMore = { + state.eventSink(BookDetailUiEvent.OnLoadMore) + }, + ) { + item { + ReedBackTopAppBar( + title = "", + onBackClick = { + state.eventSink(BookDetailUiEvent.OnBackClick) }, - text = stringResource(R.string.register_book_record), - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - modifier = Modifier.weight(1f), ) } - } - } - item { - if (state.hasEmotionData()) { - CollectedSeeds(seedsStats = state.seedsStats) - } else { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) - } + item { + Column { + BookItem(bookDetail = state.bookDetail) + Spacer(Modifier.height(28.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing5), + ) { + ReedButton( + onClick = { + state.eventSink(BookDetailUiEvent.OnBookStatusButtonClick) + }, + text = stringResource( + BookStatus.fromValue(state.bookDetail.userBookStatus)?.getDisplayNameRes() + ?: BookStatus.BEFORE_READING.getDisplayNameRes(), + ), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + modifier = Modifier.widthIn(min = 98.dp), + trailingIcon = { + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_chevron_down), + contentDescription = "Dropdown Icon", + modifier = Modifier.size(22.dp), + tint = ReedTheme.colors.contentPrimary, + ) + }, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + ReedButton( + onClick = { + state.eventSink(BookDetailUiEvent.OnRegisterRecordButtonClick) + }, + text = stringResource(R.string.register_book_record), + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + modifier = Modifier.weight(1f), + ) + } + } + } - ReedDivider() - } + item { + if (state.hasEmotionData()) { + CollectedSeeds(seedsStats = state.seedsStats) + } else { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) + } - item { - Column( - modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), - ) { - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) - ReadingRecordsHeader( - readingRecords = state.readingRecords, - currentRecordSort = state.currentRecordSort, - onReadingRecordClick = { - state.eventSink(BookDetailUiEvent.OnRecordSortButtonClick) - }, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) - } - } + ReedDivider() + } - if (state.readingRecords.isEmpty()) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .height(324.dp) - .padding(horizontal = ReedTheme.spacing.spacing5), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.records_collection_empty), - color = ReedTheme.colors.contentSecondary, - textAlign = TextAlign.Center, - style = ReedTheme.typography.body1Medium, - ) + item { + Column( + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5), + ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + ReadingRecordsHeader( + readingRecords = state.readingRecords, + currentRecordSort = state.currentRecordSort, + onReadingRecordClick = { + state.eventSink(BookDetailUiEvent.OnRecordSortButtonClick) + }, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + } } - } - } else { - items( - count = state.readingRecords.size, - key = { index -> state.readingRecords[index].id }, - ) { index -> - val record = state.readingRecords[index] - RecordItem( - quote = record.quote, - emotionTags = record.emotionTags.toImmutableList(), - pageNumber = record.pageNumber, - createdAt = record.createdAt.toFormattedDate(), - modifier = Modifier - .padding( - start = ReedTheme.spacing.spacing5, - end = ReedTheme.spacing.spacing5, - bottom = ReedTheme.spacing.spacing3, + + if (state.readingRecords.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(324.dp) + .padding(horizontal = ReedTheme.spacing.spacing5), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.records_collection_empty), + color = ReedTheme.colors.contentSecondary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.body1Medium, + ) + } + } + } else { + items( + count = state.readingRecords.size, + key = { index -> state.readingRecords[index].id }, + ) { index -> + val record = state.readingRecords[index] + RecordItem( + quote = record.quote, + emotionTags = record.emotionTags.toImmutableList(), + pageNumber = record.pageNumber, + createdAt = record.createdAt.toFormattedDate(), + modifier = Modifier + .padding( + start = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + bottom = ReedTheme.spacing.spacing3, + ) + .clickable { + state.eventSink(BookDetailUiEvent.OnRecordItemClick(record.id)) + }, ) - .clickable { - state.eventSink(BookDetailUiEvent.OnRecordItemClick(record.id)) - }, - ) - } + } - item { - LoadStateFooter( - footerState = state.footerState, - onRetryClick = { state.eventSink(BookDetailUiEvent.OnLoadMore) }, - ) + item { + LoadStateFooter( + footerState = state.footerState, + onRetryClick = { state.eventSink(BookDetailUiEvent.OnLoadMore) }, + ) + } + } } } + + is UiState.Error -> { + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { state.eventSink(BookDetailUiEvent.OnRetryClick) }, + ) + } } } @@ -273,6 +296,7 @@ private fun BookDetailPreview() { ReedTheme { BookDetailUi( state = BookDetailUiState( + uiState = UiState.Success, eventSink = {}, ), ) diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt index aca81294..4b58683e 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt @@ -17,7 +17,7 @@ sealed interface UiState { data object Idle : UiState data object Loading : UiState data object Success : UiState - data class Error(val message: String) : UiState + data class Error(val exception: Throwable) : UiState } data class BookDetailUiState( @@ -63,6 +63,7 @@ sealed interface BookDetailUiEvent : CircuitUiEvent { data class OnRecordSortItemSelected(val sortType: RecordSort) : BookDetailUiEvent data class OnRecordItemClick(val recordId: String) : BookDetailUiEvent data object OnLoadMore : BookDetailUiEvent + data object OnRetryClick : BookDetailUiEvent } enum class RecordSort(val value: String) { diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt index a2a843d5..44d88ef6 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt @@ -89,7 +89,7 @@ internal fun RecordItem( } } -private fun getEmotionImageResourceByDisplayName(displayName: String): Int { +fun getEmotionImageResourceByDisplayName(displayName: String): Int { return when (displayName) { "따뜻함" -> R.drawable.img_warm "즐거움" -> R.drawable.img_joy diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt index 3cc14692..9b65abef 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt @@ -31,17 +31,21 @@ class RecordDetailPresenter @AssistedInject constructor( @Composable override fun present(): RecordDetailUiState { val scope = rememberCoroutineScope() - + var uiState by rememberRetained { mutableStateOf(UiState.Idle) } var recordDetailInfo by rememberRetained { mutableStateOf(RecordDetailModel()) } var sideEffect by rememberRetained { mutableStateOf(null) } fun getRecordDetail(readingRecordId: String) { scope.launch { + uiState = UiState.Loading + repository.getRecordDetail(readingRecordId = readingRecordId) .onSuccess { result -> recordDetailInfo = result + uiState = UiState.Success } .onFailure { exception -> + uiState = UiState.Error(exception) val handleErrorMessage = { message: String -> Logger.e(message) sideEffect = RecordDetailSideEffect.ShowToast(message) @@ -63,6 +67,10 @@ class RecordDetailPresenter @AssistedInject constructor( RecordDetailUiEvent.OnCloseClicked -> { navigator.pop() } + + RecordDetailUiEvent.onRetryClick -> { + getRecordDetail(screen.recordId) + } } } @@ -71,6 +79,7 @@ class RecordDetailPresenter @AssistedInject constructor( } return RecordDetailUiState( + uiState = uiState, recordDetailInfo = recordDetailInfo, sideEffect = sideEffect, eventSink = ::handleEvent, diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt index dfe228bb..fb3f5900 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.detail.record +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -29,6 +31,7 @@ import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.core.model.RecordDetailModel import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedErrorUi import com.ninecraft.booket.core.ui.component.ReedTopAppBar import com.ninecraft.booket.feature.detail.R import com.ninecraft.booket.feature.detail.record.component.QuoteBox @@ -78,98 +81,119 @@ private fun ReviewDetailContent( state.eventSink(RecordDetailUiEvent.OnCloseClicked) }, ) - Row( - modifier = modifier - .fillMaxWidth() - .padding( - horizontal = ReedTheme.spacing.spacing5, - vertical = ReedTheme.spacing.spacing4, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - NetworkImage( - imageUrl = state.recordDetailInfo.bookCoverImageUrl, - contentDescription = "Book CoverImage", - modifier = Modifier - .padding(end = ReedTheme.spacing.spacing4) - .width(46.dp) - .height(68.dp) - .clip(RoundedCornerShape(size = ReedTheme.radius.xs)), - placeholder = painterResource(designR.drawable.ic_placeholder), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = state.recordDetailInfo.bookTitle, - color = ReedTheme.colors.contentPrimary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = ReedTheme.typography.body1SemiBold, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) - BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val authorMaxWidth = maxWidth * 0.7f + when (state.uiState) { + is UiState.Idle -> {} + is UiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = ReedTheme.colors.contentBrand) + } + } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { + is UiState.Success -> { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = ReedTheme.spacing.spacing5, + vertical = ReedTheme.spacing.spacing4, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + NetworkImage( + imageUrl = state.recordDetailInfo.bookCoverImageUrl, + contentDescription = "Book CoverImage", + modifier = Modifier + .padding(end = ReedTheme.spacing.spacing4) + .width(46.dp) + .height(68.dp) + .clip(RoundedCornerShape(size = ReedTheme.radius.xs)), + placeholder = painterResource(designR.drawable.ic_placeholder), + ) + Column(modifier = Modifier.weight(1f)) { Text( - text = state.recordDetailInfo.author, - color = ReedTheme.colors.contentTertiary, + text = state.recordDetailInfo.bookTitle, + color = ReedTheme.colors.contentPrimary, overflow = TextOverflow.Ellipsis, maxLines = 1, - style = ReedTheme.typography.label1Medium, - modifier = Modifier.widthIn(max = authorMaxWidth), - ) - Spacer(Modifier.width(ReedTheme.spacing.spacing1)) - VerticalDivider( - modifier = Modifier.height(14.dp), - thickness = 1.dp, - color = ReedTheme.colors.contentTertiary, - ) - Spacer(Modifier.width(ReedTheme.spacing.spacing1)) - Text( - text = state.recordDetailInfo.bookPublisher, - color = ReedTheme.colors.contentTertiary, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - style = ReedTheme.typography.label1Medium, - modifier = Modifier.weight(1f, fill = false), + style = ReedTheme.typography.body1SemiBold, ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val authorMaxWidth = maxWidth * 0.7f + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = state.recordDetailInfo.author, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.widthIn(max = authorMaxWidth), + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + VerticalDivider( + modifier = Modifier.height(14.dp), + thickness = 1.dp, + color = ReedTheme.colors.contentTertiary, + ) + Spacer(Modifier.width(ReedTheme.spacing.spacing1)) + Text( + text = state.recordDetailInfo.bookPublisher, + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label1Medium, + modifier = Modifier.weight(1f, fill = false), + ) + } + } } } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReedDivider() + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = ReedTheme.spacing.spacing5), + ) { + Text( + text = stringResource(R.string.review_detail_quote_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + QuoteBox( + quote = state.recordDetailInfo.quote, + page = state.recordDetailInfo.pageNumber, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) + Text( + text = stringResource(R.string.review_detail_impression_label), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + ReviewBox( + emotion = state.recordDetailInfo.emotionTags.getOrNull(0) ?: "", + createdAt = state.recordDetailInfo.createdAt, + review = state.recordDetailInfo.review, + ) + } + } + + is UiState.Error -> { + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { }, + ) } - } - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReedDivider() - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = ReedTheme.spacing.spacing5), - ) { - Text( - text = stringResource(R.string.review_detail_quote_label), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.body1Medium, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - QuoteBox( - quote = state.recordDetailInfo.quote, - page = state.recordDetailInfo.pageNumber, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6)) - Text( - text = stringResource(R.string.review_detail_impression_label), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.body1Medium, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReviewBox( - emotion = state.recordDetailInfo.emotionTags.getOrNull(0) ?: "", - createdAt = state.recordDetailInfo.createdAt, - review = state.recordDetailInfo.review, - ) } } } @@ -180,6 +204,7 @@ private fun ReviewDetailPreview() { ReedTheme { RecordDetailUi( state = RecordDetailUiState( + uiState = UiState.Success, recordDetailInfo = RecordDetailModel( id = "", userBookId = "", diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt index 7b41d9ca..a472fc1b 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt @@ -6,7 +6,15 @@ import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.util.UUID +sealed interface UiState { + data object Idle : UiState + data object Loading : UiState + data object Success : UiState + data class Error(val exception: Throwable) : UiState +} + data class RecordDetailUiState( + val uiState: UiState = UiState.Idle, val recordDetailInfo: RecordDetailModel = RecordDetailModel(), val sideEffect: RecordDetailSideEffect? = null, val eventSink: (RecordDetailUiEvent) -> Unit, @@ -22,4 +30,5 @@ sealed interface RecordDetailSideEffect { sealed interface RecordDetailUiEvent : CircuitUiEvent { data object OnCloseClicked : RecordDetailUiEvent + data object onRetryClick : RecordDetailUiEvent } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt index a9e297b8..42aa6103 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.detail.record.component +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,8 +18,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.feature.detail.book.component.getEmotionImageResourceByDisplayName @Composable fun ReviewBox( @@ -44,14 +47,12 @@ fun ReviewBox( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - Box( + Image( + painter = painterResource(getEmotionImageResourceByDisplayName(emotion)), + contentDescription = "Emotion Graphic", modifier = Modifier .size(ReedTheme.spacing.spacing10) - .background( - color = ReedTheme.colors.bgTertiary, - shape = CircleShape, - ) - .clip(shape = CircleShape), + .clip(CircleShape), ) Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) Text( diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt index 1067c8f4..7fa8440a 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomePresenter.kt @@ -6,16 +6,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.RecentBookModel import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.HomeScreen -import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.RecordScreen import com.ninecraft.booket.feature.screens.SearchScreen import com.ninecraft.booket.feature.screens.SettingsScreen -import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator @@ -38,12 +35,11 @@ class HomePresenter @AssistedInject constructor( val scope = rememberCoroutineScope() var uiState by rememberRetained { mutableStateOf(UiState.Idle) } - var sideEffect by rememberRetained { mutableStateOf(null) } var recentBooks by rememberRetained { mutableStateOf(persistentListOf()) } fun loadHomeContent() { scope.launch { - if (uiState == UiState.Idle || uiState == UiState.Error) { + if (uiState is UiState.Idle || uiState is UiState.Error) { uiState = UiState.Loading } @@ -52,19 +48,7 @@ class HomePresenter @AssistedInject constructor( uiState = UiState.Success recentBooks = result.recentBooks.toPersistentList() }.onFailure { exception -> - uiState = UiState.Error - val handleErrorMessage = { message: String -> - Logger.e(message) - sideEffect = HomeSideEffect.ShowToast(message) - } - - handleException( - exception = exception, - onError = handleErrorMessage, - onLoginRequired = { - navigator.resetRoot(LoginScreen) - }, - ) + uiState = UiState.Error(exception) } } } diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt index bfd29c96..f75e7f43 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUi.kt @@ -24,12 +24,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.DevicePreview -import com.ninecraft.booket.core.designsystem.component.button.ReedButton -import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle -import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle import com.ninecraft.booket.core.designsystem.theme.HomeBg import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.ui.ReedScaffold +import com.ninecraft.booket.core.ui.component.ReedErrorUi import com.ninecraft.booket.feature.home.component.BookCard import com.ninecraft.booket.feature.home.component.EmptyBookCard import com.ninecraft.booket.feature.home.component.HomeBanner @@ -170,29 +168,10 @@ internal fun HomeContent( } is UiState.Error -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.home_error_title), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.headline1SemiBold, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReedButton( - onClick = { - state.eventSink(HomeUiEvent.OnRetryClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - text = stringResource(R.string.home_retry), - ) - } - } + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { state.eventSink(HomeUiEvent.OnRetryClick) }, + ) } } } diff --git a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt index d9d09718..ca353cde 100644 --- a/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt +++ b/feature/home/src/main/kotlin/com/ninecraft/booket/feature/home/HomeUiState.kt @@ -13,7 +13,7 @@ sealed interface UiState { data object Idle : UiState data object Loading : UiState data object Success : UiState - data object Error : UiState + data class Error(val exception: Throwable) : UiState } data class HomeUiState( diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt index 9a172915..40dbedb3 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryPresenter.kt @@ -91,7 +91,7 @@ class LibraryPresenter @AssistedInject constructor( Logger.d(exception) val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." if (page == START_INDEX) { - uiState = UiState.Error(errorMessage) + uiState = UiState.Error(exception) } else { footerState = FooterState.Error(errorMessage) } @@ -114,6 +114,10 @@ class LibraryPresenter @AssistedInject constructor( } is LibraryUiEvent.OnFilterClick -> { + if (currentFilter == event.filterOption) { + return + } + currentFilter = event.filterOption filterLibraryBooks(status = currentFilter.getApiValue(), page = START_INDEX, size = PAGE_SIZE) } @@ -148,13 +152,11 @@ class LibraryPresenter @AssistedInject constructor( } LaunchedEffect(Unit) { - if (uiState == UiState.Idle || uiState is UiState.Error) { - filterLibraryBooks( - status = currentFilter.getApiValue(), - page = START_INDEX, - size = PAGE_SIZE, - ) - } + filterLibraryBooks( + status = currentFilter.getApiValue(), + page = START_INDEX, + size = PAGE_SIZE, + ) } return LibraryUiState( diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt index 7d47faea..32be5462 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUi.kt @@ -18,14 +18,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.ninecraft.booket.core.designsystem.DevicePreview -import com.ninecraft.booket.core.designsystem.component.button.ReedButton -import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorStyle -import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle import com.ninecraft.booket.core.designsystem.theme.ReedTheme import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter +import com.ninecraft.booket.core.ui.component.ReedErrorUi import com.ninecraft.booket.feature.library.component.FilterChipGroup import com.ninecraft.booket.feature.library.component.LibraryBookItem import com.ninecraft.booket.feature.library.component.LibraryHeader @@ -149,7 +147,10 @@ internal fun LibraryContent( } is UiState.Error -> { - ErrorResult(state = state, errorMessage = state.uiState.message) + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { state.eventSink(LibraryUiEvent.OnRetryClick) }, + ) } } } @@ -179,39 +180,6 @@ private fun EmptyResult() { } } -@Composable -private fun ErrorResult(state: LibraryUiState, errorMessage: String) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = stringResource(R.string.library_error_title), - color = ReedTheme.colors.contentPrimary, - style = ReedTheme.typography.headline1SemiBold, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - Text( - text = errorMessage, - color = ReedTheme.colors.contentSecondary, - style = ReedTheme.typography.body1Medium, - ) - Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) - ReedButton( - onClick = { - state.eventSink(LibraryUiEvent.OnRetryClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - text = stringResource(R.string.library_retry), - ) - } - } -} - @DevicePreview @Composable private fun LibraryPreview() { diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt index 5294589e..829a4a43 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/LibraryUiState.kt @@ -14,7 +14,7 @@ sealed interface UiState { data object Idle : UiState data object Loading : UiState data object Success : UiState - data class Error(val message: String) : UiState + data class Error(val exception: Throwable) : UiState } data class LibraryUiState( diff --git a/feature/library/src/main/res/values/strings.xml b/feature/library/src/main/res/values/strings.xml index 68d5b7f8..145b3d4a 100644 --- a/feature/library/src/main/res/values/strings.xml +++ b/feature/library/src/main/res/values/strings.xml @@ -8,6 +8,4 @@ 완독 아직 등록된 책이 없어요 도서 등록 후 나만의 아카이브를 만들어보세요 - 책 정보를 가져오는데 실패했어요 - 다시 시도 diff --git a/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt b/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt index f20f85e9..4ee9010b 100644 --- a/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt +++ b/feature/main/src/main/kotlin/com/ninecraft/booket/feature/main/MainActivity.kt @@ -6,10 +6,17 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.ninecraft.booket.core.common.constants.ErrorDialogSpec +import com.ninecraft.booket.core.common.event.ErrorEvent +import com.ninecraft.booket.core.common.event.ErrorEventHelper import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.component.ReedDialog import com.ninecraft.booket.feature.screens.SplashScreen import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.Circuit @@ -47,6 +54,33 @@ class MainActivity : ComponentActivity() { val backStack = rememberSaveableBackStack(root = SplashScreen) val navigator = rememberCircuitNavigator(backStack) + val dialogSpec = remember { mutableStateOf(null) } + + // 전역 에러 수신 + LaunchedEffect(Unit) { + ErrorEventHelper.errorEvent.collect { event -> + when (event) { + is ErrorEvent.ShowDialog -> { + dialogSpec.value = event.spec + } + } + } + } + + dialogSpec.value?.let { spec -> + ReedDialog( + description = spec.message, + confirmButtonText = spec.buttonLabel, + onConfirmRequest = { + spec.action() + dialogSpec.value = null + }, + onDismissRequest = { + dialogSpec.value = null + }, + ) + } + CircuitCompositionLocals(circuit) { NavigableCircuitContent( navigator = navigator, diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt index 818d1046..ba892739 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterPresenter.kt @@ -10,7 +10,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.text.TextRange +import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.RecordRepository import com.ninecraft.booket.core.designsystem.EmotionTag import com.ninecraft.booket.core.designsystem.RecordStep @@ -79,9 +81,11 @@ class RecordRegisterPresenter @AssistedInject constructor( RecordStep.QUOTE -> { recordPageState.text.isNotEmpty() && recordSentenceState.text.isNotEmpty() && !isPageError } + RecordStep.EMOTION -> { selectedEmotion != null } + RecordStep.IMPRESSION -> { impressionState.text.isNotEmpty() } @@ -114,6 +118,11 @@ class RecordRegisterPresenter @AssistedInject constructor( savedRecordId = result.id isRecordSavedDialogVisible = true }.onFailure { exception -> + postErrorDialog( + errorScope = ErrorScope.RECORD_REGISTER, + exception = exception, + ) + val handleErrorMessage = { message: String -> Logger.e(message) sideEffect = RecordRegisterSideEffect.ShowToast(message) diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt index 4284e927..e5516b38 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchPresenter.kt @@ -9,7 +9,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.ninecraft.booket.core.common.constants.BookStatus +import com.ninecraft.booket.core.common.constants.ErrorScope import com.ninecraft.booket.core.common.utils.handleException +import com.ninecraft.booket.core.common.utils.postErrorDialog import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.BookSearchModel import com.ninecraft.booket.core.model.BookSummaryModel @@ -90,7 +92,7 @@ class BookSearchPresenter @AssistedInject constructor( Logger.d(exception) val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." if (startIndex == START_INDEX) { - uiState = UiState.Error(errorMessage) + uiState = UiState.Error(exception) } else { footerState = FooterState.Error(errorMessage) } @@ -115,6 +117,11 @@ class BookSearchPresenter @AssistedInject constructor( isBookRegisterSuccessBottomSheetVisible = true } .onFailure { exception -> + postErrorDialog( + errorScope = ErrorScope.BOOK_REGISTER, + exception = exception, + ) + val handleErrorMessage = { message: String -> Logger.e(message) sideEffect = BookSearchSideEffect.ShowToast(message) diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt index 9b48220d..436e7b7d 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUi.kt @@ -1,7 +1,6 @@ package com.ninecraft.booket.feature.search.book import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -33,6 +31,7 @@ import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar +import com.ninecraft.booket.core.ui.component.ReedErrorUi import com.ninecraft.booket.feature.screens.SearchScreen import com.ninecraft.booket.feature.search.R import com.ninecraft.booket.feature.search.book.component.BookItem @@ -125,22 +124,10 @@ internal fun SearchContent( } is UiState.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = state.uiState.message, - style = ReedTheme.typography.body1Regular, - ) - Button( - onClick = { state.eventSink(BookSearchUiEvent.OnRetryClick) }, - modifier = Modifier.padding(top = ReedTheme.spacing.spacing3), - ) { - Text(text = stringResource(R.string.retry)) - } - } + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { state.eventSink(BookSearchUiEvent.OnRetryClick) }, + ) } is UiState.Idle -> { diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt index 770738c2..3f534a38 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/book/BookSearchUiState.kt @@ -16,7 +16,7 @@ sealed interface UiState { data object Idle : UiState data object Loading : UiState data object Success : UiState - data class Error(val message: String) : UiState + data class Error(val exception: Throwable) : UiState } data class BookSearchUiState( diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt index ae31c4c6..e15b8e35 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchPresenter.kt @@ -82,7 +82,7 @@ class LibrarySearchPresenter @AssistedInject constructor( .onFailure { exception -> val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다." if (page == START_INDEX) { - uiState = UiState.Error(errorMessage) + uiState = UiState.Error(exception) } else { footerState = FooterState.Error(errorMessage) } diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt index 0e03ae77..b40cb66e 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUi.kt @@ -1,6 +1,5 @@ package com.ninecraft.booket.feature.search.library -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text @@ -28,6 +26,7 @@ import com.ninecraft.booket.core.ui.ReedScaffold import com.ninecraft.booket.core.ui.component.InfinityLazyColumn import com.ninecraft.booket.core.ui.component.LoadStateFooter import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar +import com.ninecraft.booket.core.ui.component.ReedErrorUi import com.ninecraft.booket.feature.screens.LibrarySearchScreen import com.ninecraft.booket.feature.search.R import com.ninecraft.booket.feature.search.common.component.RecentSearchTitle @@ -105,22 +104,10 @@ internal fun LibrarySearchContent( } is UiState.Error -> { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text( - text = state.uiState.message, - style = ReedTheme.typography.body1Regular, - ) - Button( - onClick = { state.eventSink(LibrarySearchUiEvent.OnRetryClick) }, - modifier = Modifier.padding(top = ReedTheme.spacing.spacing3), - ) { - Text(text = stringResource(R.string.retry)) - } - } + ReedErrorUi( + exception = state.uiState.exception, + onRetryClick = { state.eventSink(LibrarySearchUiEvent.OnRetryClick) }, + ) } is UiState.Idle -> { diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt index 121a0687..f6d2c9f1 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/library/LibrarySearchUiState.kt @@ -13,7 +13,7 @@ sealed interface UiState { data object Idle : UiState data object Loading : UiState data object Success : UiState - data class Error(val message: String) : UiState + data class Error(val exception: Throwable) : UiState } data class LibrarySearchUiState( diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index cf14f2c2..fe971294 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ 총  최근 검색어 - 다시 시도 오류가 발생했습니다 검색어와 일치하는 도서가 없습니다 등록 옵션