diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 4311a8da..35666a1b 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -16,6 +16,8 @@ dependencies { projects.core.model, projects.core.network, + libs.kotlinx.collections.immutable, + libs.logger, ) } diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/BookStatus.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/BookStatus.kt new file mode 100644 index 00000000..6745d507 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/constants/BookStatus.kt @@ -0,0 +1,24 @@ +package com.ninecraft.booket.core.common.constants + +import com.ninecraft.booket.core.common.R + +enum class BookStatus(val value: String) { + BEFORE_READING("BEFORE_READING"), + READING("READING"), + COMPLETED("COMPLETED"), + ; + + fun getDisplayNameRes(): Int { + return when (this) { + BEFORE_READING -> R.string.book_status_before + READING -> R.string.book_status_reading + COMPLETED -> R.string.book_status_completed + } + } + + companion object Companion { + fun fromValue(value: String): BookStatus? { + return entries.find { it.value == value } + } + } +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Emotion.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Emotion.kt new file mode 100644 index 00000000..a8b01810 --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/extensions/Emotion.kt @@ -0,0 +1,22 @@ +package com.ninecraft.booket.core.common.extensions + +import androidx.compose.ui.graphics.Color +import com.ninecraft.booket.core.model.Emotion + +fun Emotion.toTextColor(): Color { + return when (this) { + Emotion.WARM -> Color(0xFFE3931B) + Emotion.JOY -> Color(0xFFEE6B33) + Emotion.TENSION -> Color(0xFF9A55E4) + Emotion.SADNESS -> Color(0xFF2872E9) + } +} + +fun Emotion.toBackgroundColor(): Color { + return when (this) { + Emotion.WARM -> Color(0xFFFFF5D3) + Emotion.JOY -> Color(0xFFFFEBE3) + Emotion.TENSION -> Color(0xFFF3E8FF) + Emotion.SADNESS -> Color(0xFFE1ECFF) + } +} diff --git a/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/EmotionAnalyzer.kt b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/EmotionAnalyzer.kt new file mode 100644 index 00000000..741853cb --- /dev/null +++ b/core/common/src/main/kotlin/com/ninecraft/booket/core/common/util/EmotionAnalyzer.kt @@ -0,0 +1,31 @@ +package com.ninecraft.booket.core.common.util + +import com.ninecraft.booket.core.model.EmotionModel + +data class EmotionAnalysisResult( + val topEmotions: List, + val displayType: EmotionDisplayType, +) + +enum class EmotionDisplayType { + SINGLE, // 1개 감정이 1위 + DUAL, // 2개 감정이 공동 1위 + BALANCED, // 3개 이상 감정이 공동 1위 +} + +fun analyzeEmotions(emotions: List): EmotionAnalysisResult { + if (emotions.isEmpty()) { + return EmotionAnalysisResult(emptyList(), EmotionDisplayType.BALANCED) + } + + val maxCount = emotions.maxOf { it.count } + val topEmotions = emotions.filter { it.count == maxCount } + + val displayType = when (topEmotions.size) { + 1 -> EmotionDisplayType.SINGLE + 2 -> EmotionDisplayType.DUAL + else -> EmotionDisplayType.BALANCED + } + + return EmotionAnalysisResult(topEmotions, displayType) +} diff --git a/core/common/src/main/res/values/strings.xml b/core/common/src/main/res/values/strings.xml new file mode 100644 index 00000000..71ff450b --- /dev/null +++ b/core/common/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + 읽기 전 + 읽는 중 + 독서 완료 + 페이지순 + 최신 등록순 + diff --git a/core/model/src/main/kotlin/com/ninecraft/booket/core/model/EmotionModel.kt b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/EmotionModel.kt new file mode 100644 index 00000000..77a80d19 --- /dev/null +++ b/core/model/src/main/kotlin/com/ninecraft/booket/core/model/EmotionModel.kt @@ -0,0 +1,18 @@ +package com.ninecraft.booket.core.model + +import androidx.compose.runtime.Stable + +@Stable +data class EmotionModel( + val type: Emotion, + val count: Int, +) + +enum class Emotion( + val displayName: String, +) { + WARM("따뜻함"), + JOY("즐거움"), + TENSION("긴장감"), + SADNESS("슬픔"), +} diff --git a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt index a915d9a3..572a0684 100644 --- a/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/ninecraft/booket/core/network/di/NetworkModule.kt @@ -24,9 +24,13 @@ import javax.inject.Singleton private const val MaxTimeoutMillis = 15_000L private val jsonRule = Json { + // 기본값도 JSON에 포함하여 직렬화 encodeDefaults = true + // JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지) ignoreUnknownKeys = true + // JSON을 보기 좋게 들여쓰기하여 포맷팅 prettyPrint = true + // 엄격하지 않은 파싱 (따옴표 없는 키, 후행 쉼표 등 허용) isLenient = true } diff --git a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/LoadStateFooter.kt b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/LoadStateFooter.kt index 98696f7a..558dcfe6 100644 --- a/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/LoadStateFooter.kt +++ b/core/ui/src/main/kotlin/com/ninecraft/booket/core/ui/component/LoadStateFooter.kt @@ -10,6 +10,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -68,6 +69,7 @@ fun LoadStateFooter( } } +@Immutable sealed interface FooterState { data object Idle : FooterState data object Loading : FooterState diff --git a/feature/detail/build.gradle.kts b/feature/detail/build.gradle.kts index 971dde8f..c3af32c6 100644 --- a/feature/detail/build.gradle.kts +++ b/feature/detail/build.gradle.kts @@ -16,6 +16,8 @@ ksp { dependencies { implementations( + libs.kotlinx.collections.immutable, + libs.logger, ) } 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 a82caa24..5d4c45d2 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 @@ -5,10 +5,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.constants.BookStatus import com.ninecraft.booket.core.common.utils.handleException import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.feature.screens.BookDetailScreen import com.ninecraft.booket.feature.screens.LoginScreen +import com.ninecraft.booket.feature.screens.RecordDetailScreen +import com.ninecraft.booket.feature.screens.RecordScreen import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained @@ -30,6 +33,10 @@ class BookDetailPresenter @AssistedInject constructor( override fun present(): BookDetailUiState { val scope = rememberCoroutineScope() + var isBookUpdateBottomSheetVisible by rememberRetained { mutableStateOf(false) } + var isRecordSortBottomSheetVisible by rememberRetained { mutableStateOf(false) } + var currentBookStatus by rememberRetained { mutableStateOf(BookStatus.READING) } + var currentRecordSort by rememberRetained { mutableStateOf(RecordSort.PAGE_ASCENDING) } var sideEffect by rememberRetained { mutableStateOf(null) } fun upsertBook(bookIsbn: String, bookStatus: String) { @@ -57,29 +64,58 @@ class BookDetailPresenter @AssistedInject constructor( fun handleEvent(event: BookDetailUiEvent) { when (event) { - BookDetailUiEvent.InitSideEffect -> { + is BookDetailUiEvent.InitSideEffect -> { sideEffect = null } - BookDetailUiEvent.OnBackClicked -> { + is BookDetailUiEvent.OnBackClick -> { navigator.pop() } - BookDetailUiEvent.OnBeforeReadingClick -> { - upsertBook(screen.isbn, "BEFORE_READING") + is BookDetailUiEvent.OnBookStatusButtonClick -> { + isBookUpdateBottomSheetVisible = true } - BookDetailUiEvent.OnReadingClick -> { - upsertBook(screen.isbn, "READING") + is BookDetailUiEvent.OnRegisterRecordButtonClick -> { + navigator.goTo(RecordScreen("")) } - BookDetailUiEvent.OnCompletedClick -> { - upsertBook(screen.isbn, "COMPLETED") + is BookDetailUiEvent.OnRecordSortButtonClick -> { + isRecordSortBottomSheetVisible = true + } + + is BookDetailUiEvent.OnBookUpdateBottomSheetDismiss -> { + isBookUpdateBottomSheetVisible = false + } + + is BookDetailUiEvent.OnBookStatusItemSelected -> { + currentBookStatus = event.bookStatus + } + + is BookDetailUiEvent.OnBookStatusUpdateButtonClick -> { + upsertBook(screen.isbn, currentBookStatus.value) + } + + is BookDetailUiEvent.OnRecordSortBottomSheetDismiss -> { + isRecordSortBottomSheetVisible = false + } + + is BookDetailUiEvent.OnRecordSortItemSelected -> { + currentRecordSort = event.sortType + isRecordSortBottomSheetVisible = false + } + + is BookDetailUiEvent.OnRecordItemClick -> { + navigator.goTo(RecordDetailScreen(event.recordId)) } } } return BookDetailUiState( + isBookUpdateBottomSheetVisible = isBookUpdateBottomSheetVisible, + isRecordSortBottomSheetVisible = isRecordSortBottomSheetVisible, + currentBookStatus = currentBookStatus, + currentRecordSort = currentRecordSort, sideEffect = sideEffect, eventSink = ::handleEvent, ) 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 44ef78f0..c0f98253 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 @@ -1,29 +1,64 @@ package com.ninecraft.booket.feature.detail.book -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.designsystem.ComponentPreview -import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar +import com.ninecraft.booket.core.designsystem.component.NetworkImage +import com.ninecraft.booket.core.designsystem.component.ReedDivider 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.ui.component.ReedBackTopAppBar import com.ninecraft.booket.core.ui.component.ReedFullScreen +import com.ninecraft.booket.feature.detail.book.component.BookUpdateBottomSheet +import com.ninecraft.booket.feature.detail.book.component.CollectedSeed +import com.ninecraft.booket.feature.detail.book.component.RecordSortBottomSheet +import com.ninecraft.booket.feature.detail.book.component.RecordsCollection import com.ninecraft.booket.feature.screens.BookDetailScreen import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import com.ninecraft.booket.core.designsystem.R as designR +@OptIn(ExperimentalMaterial3Api::class) @CircuitInject(BookDetailScreen::class, ActivityRetainedComponent::class) @Composable -fun BookDetail( +internal fun BookDetailUi( state: BookDetailUiState, modifier: Modifier = Modifier, ) { + val bookUpdateBottomSheetState = rememberModalBottomSheetState() + val recordSortBottomSheetState = rememberModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + HandleBookDetailSideEffects( state = state, eventSink = state.eventSink, @@ -31,46 +66,174 @@ fun BookDetail( ReedFullScreen(modifier = modifier) { ReedBackTopAppBar( - title = "도서 상세 정보", + title = "", onBackClick = { - state.eventSink(BookDetailUiEvent.OnBackClicked) + state.eventSink(BookDetailUiEvent.OnBackClick) + }, + ) + BookDetailContent(state = state) + } + + if (state.isBookUpdateBottomSheetVisible) { + BookUpdateBottomSheet( + onDismissRequest = { + state.eventSink(BookDetailUiEvent.OnBookUpdateBottomSheetDismiss) + }, + sheetState = bookUpdateBottomSheetState, + onCloseButtonClick = { + coroutineScope.launch { + bookUpdateBottomSheetState.hide() + state.eventSink(BookDetailUiEvent.OnBookUpdateBottomSheetDismiss) + } + }, + bookStatuses = BookStatus.entries.toTypedArray().toImmutableList(), + currentBookStatus = state.currentBookStatus, + onItemSelected = { + state.eventSink(BookDetailUiEvent.OnBookStatusItemSelected(it)) + }, + onBookUpdateButtonClick = { + state.eventSink(BookDetailUiEvent.OnBookStatusUpdateButtonClick) + }, + ) + } + + if (state.isRecordSortBottomSheetVisible) { + RecordSortBottomSheet( + onDismissRequest = { + state.eventSink(BookDetailUiEvent.OnRecordSortBottomSheetDismiss) + }, + sheetState = recordSortBottomSheetState, + onCloseButtonClick = { + coroutineScope.launch { + recordSortBottomSheetState.hide() + state.eventSink(BookDetailUiEvent.OnRecordSortBottomSheetDismiss) + } + }, + recordSortItems = RecordSort.entries.toTypedArray().toImmutableList(), + currentRecordSort = state.currentRecordSort, + onItemSelected = { + state.eventSink(BookDetailUiEvent.OnRecordSortItemSelected(it)) }, ) + } +} - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, +@Composable +internal fun BookDetailContent( + state: BookDetailUiState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing5), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing4), - ) { - ReedButton( - onClick = { - state.eventSink(BookDetailUiEvent.OnBeforeReadingClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - text = "읽기 전", - ) - ReedButton( - onClick = { - state.eventSink(BookDetailUiEvent.OnReadingClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - text = "읽는 중", + NetworkImage( + imageUrl = "", + contentDescription = "Book CoverImage", + modifier = Modifier + .padding(end = ReedTheme.spacing.spacing4) + .width(70.dp) + .height(99.dp) + .clip(RoundedCornerShape(size = ReedTheme.radius.xs)), + placeholder = painterResource(designR.drawable.ic_placeholder), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "여름은 오래 그곳에 남아", + color = ReedTheme.colors.contentPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + style = ReedTheme.typography.headline1SemiBold, ) - ReedButton( - onClick = { - state.eventSink(BookDetailUiEvent.OnCompletedClick) - }, - sizeStyle = largeButtonStyle, - colorStyle = ReedButtonColorStyle.PRIMARY, - text = "독서 완료", + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "미쓰이에 마사시", + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label2Regular, + modifier = Modifier.weight(0.7f, fill = false), + ) + 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 = "비채", + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label2Regular, + modifier = Modifier.weight(0.3f, fill = false), + ) + } + Spacer(Modifier.width(ReedTheme.spacing.spacing05)) + Text( + text = "2024년", + color = ReedTheme.colors.contentTertiary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = ReedTheme.typography.label2Regular, ) } } + Spacer(Modifier.height(ReedTheme.spacing.spacing3)) + Spacer(Modifier.height(ReedTheme.spacing.spacing4)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing5), + ) { + ReedButton( + onClick = { + state.eventSink(BookDetailUiEvent.OnBookStatusButtonClick) + }, + text = "읽는 중", + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.SECONDARY, + modifier = Modifier.weight(1f), + 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 = "독서 기록 추가", + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + modifier = Modifier.weight(2.34f), + ) + } + + if (state.recordCollections.isEmpty()) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing10)) + } else { + CollectedSeed(state = state) + } + + ReedDivider() + RecordsCollection(state = state) } } @@ -78,7 +241,7 @@ fun BookDetail( @Composable private fun BookDetailPreview() { ReedTheme { - BookDetail( + BookDetailUi( state = BookDetailUiState( 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 07f81773..46acc90f 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 @@ -1,14 +1,71 @@ package com.ninecraft.booket.feature.detail.book +import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.common.R +import com.ninecraft.booket.core.common.constants.BookStatus +import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionModel +import com.ninecraft.booket.core.model.RecordRegisterModel import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.util.UUID data class BookDetailUiState( + val isBookUpdateBottomSheetVisible: Boolean = false, + val isRecordSortBottomSheetVisible: Boolean = false, + val emotionList: ImmutableList = persistentListOf( + EmotionModel( + type = Emotion.WARM, + count = 3, + ), + EmotionModel( + type = Emotion.JOY, + count = 1, + ), + EmotionModel( + type = Emotion.TENSION, + count = 2, + ), + EmotionModel( + type = Emotion.SADNESS, + count = 2, + ), + ), + val currentBookStatus: BookStatus = BookStatus.BEFORE_READING, + val currentRecordSort: RecordSort = RecordSort.PAGE_ASCENDING, + val recordCollections: ImmutableList = persistentListOf( + RecordRegisterModel( + id = "0", + pageNumber = 12, + quote = "“책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.“", + createdAt = "2025.06.25", + ), + RecordRegisterModel( + id = "1", + pageNumber = 13, + quote = "“책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.“", + createdAt = "2025.06.25", + ), + RecordRegisterModel( + id = "2", + pageNumber = 14, + quote = "“책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.“", + createdAt = "2025.06.25", + ), + RecordRegisterModel( + id = "3", + pageNumber = 15, + quote = "“책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.“", + createdAt = "2025.06.25", + ), + ), val sideEffect: BookDetailSideEffect? = null, val eventSink: (BookDetailUiEvent) -> Unit, ) : CircuitUiState +@Immutable sealed interface BookDetailSideEffect { data class ShowToast( val message: String, @@ -18,8 +75,33 @@ sealed interface BookDetailSideEffect { sealed interface BookDetailUiEvent : CircuitUiEvent { data object InitSideEffect : BookDetailUiEvent - data object OnBackClicked : BookDetailUiEvent - data object OnBeforeReadingClick : BookDetailUiEvent - data object OnReadingClick : BookDetailUiEvent - data object OnCompletedClick : BookDetailUiEvent + data object OnBackClick : BookDetailUiEvent + data object OnBookStatusButtonClick : BookDetailUiEvent + data object OnRegisterRecordButtonClick : BookDetailUiEvent + data object OnRecordSortButtonClick : BookDetailUiEvent + data object OnBookUpdateBottomSheetDismiss : BookDetailUiEvent + data class OnBookStatusItemSelected(val bookStatus: BookStatus) : BookDetailUiEvent + data object OnBookStatusUpdateButtonClick : BookDetailUiEvent + data object OnRecordSortBottomSheetDismiss : BookDetailUiEvent + data class OnRecordSortItemSelected(val sortType: RecordSort) : BookDetailUiEvent + data class OnRecordItemClick(val recordId: String) : BookDetailUiEvent +} + +enum class RecordSort(val value: String) { + PAGE_ASCENDING("PAGE_ASCENDING"), + RECENT_REGISTER("RECENT_REGISTER"), + ; + + fun getDisplayNameRes(): Int { + return when (this) { + PAGE_ASCENDING -> R.string.record_sort_page_ascending + RECENT_REGISTER -> R.string.record_sort_recent_register + } + } + + companion object Companion { + fun fromValue(value: String): RecordSort? { + return entries.find { it.value == value } + } + } } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookUpdateBottomSheet.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookUpdateBottomSheet.kt new file mode 100644 index 00000000..6cafde4b --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/BookUpdateBottomSheet.kt @@ -0,0 +1,174 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.ninecraft.booket.core.common.constants.BookStatus +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.largeButtonStyle +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.component.ReedBottomSheet +import com.ninecraft.booket.feature.detail.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import com.ninecraft.booket.core.designsystem.R as designR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun BookUpdateBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + onCloseButtonClick: () -> Unit, + bookStatuses: ImmutableList, + currentBookStatus: BookStatus?, + onItemSelected: (BookStatus) -> Unit, + onBookUpdateButtonClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ReedBottomSheet( + onDismissRequest = { + onDismissRequest() + }, + sheetState = sheetState, + ) { + Column( + modifier = modifier + .padding( + start = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.book_update_title), + color = ReedTheme.colors.contentPrimary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.heading2SemiBold, + ) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_close), + contentDescription = "Close Icon", + modifier = Modifier.clickable { + onCloseButtonClick() + }, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing2), + ) { + bookStatuses.forEach { item -> + BookStatusItem( + item = item, + selected = item == currentBookStatus, + onClick = { + if (item != currentBookStatus) { + onItemSelected(item) + } + }, + ) + } + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing3)) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + ReedButton( + onClick = { + onBookUpdateButtonClick() + }, + sizeStyle = largeButtonStyle, + colorStyle = ReedButtonColorStyle.PRIMARY, + modifier = Modifier.fillMaxWidth(), + enabled = currentBookStatus != null, + text = stringResource(R.string.book_update_ok), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + } + } +} + +@Composable +fun RowScope.BookStatusItem( + item: BookStatus, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(ReedTheme.radius.sm)) + .background(if (selected) ReedTheme.colors.bgTertiary else ReedTheme.colors.bgSecondary) + .selectable( + selected = selected, + indication = null, + role = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + ) + .padding(vertical = ReedTheme.spacing.spacing3), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(item.getDisplayNameRes()), + color = if (selected) ReedTheme.colors.contentBrand else ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.body1Medium, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@ComponentPreview +@Composable +private fun BookUpdateBottomSheetPreview() { + val sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ReedTheme { + BookUpdateBottomSheet( + onDismissRequest = {}, + sheetState = sheetState, + onCloseButtonClick = {}, + bookStatuses = BookStatus.entries.toImmutableList(), + currentBookStatus = null, + onItemSelected = {}, + onBookUpdateButtonClick = {}, + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/CollectedSeed.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/CollectedSeed.kt new file mode 100644 index 00000000..63fec16b --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/CollectedSeed.kt @@ -0,0 +1,99 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.feature.detail.R +import com.ninecraft.booket.feature.detail.book.BookDetailUiState + +@Composable +internal fun CollectedSeed( + state: BookDetailUiState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + start = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + bottom = ReedTheme.spacing.spacing6, + ) + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .background(ReedTheme.colors.baseSecondary), + ) { + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + Text( + text = stringResource(R.string.collected_seed_title), + modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing4), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body2Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + state.emotionList.forEach { emotion -> + SeedItem(emotion = emotion) + } + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing4) + .clip(RoundedCornerShape(ReedTheme.radius.sm)) + .background(ReedTheme.colors.basePrimary) + .padding(ReedTheme.spacing.spacing3) + .border( + width = 1.dp, + color = ReedTheme.colors.basePrimary, + shape = RoundedCornerShape(ReedTheme.radius.sm), + ), + ) { + Text( + text = EmotionAnalysisResultText( + emotions = state.emotionList, + brandColor = ReedTheme.colors.contentBrand, + secondaryColor = ReedTheme.colors.contentSecondary, + emotionTextStyle = ReedTheme.typography.label2SemiBold, + regularTextStyle = ReedTheme.typography.label2Regular, + ), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + } +} + +@ComponentPreview +@Composable +private fun CollectedSeedPreview() { + ReedTheme { + CollectedSeed( + state = BookDetailUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/EmotionAnalysisResultText.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/EmotionAnalysisResultText.kt new file mode 100644 index 00000000..88f727a7 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/EmotionAnalysisResultText.kt @@ -0,0 +1,142 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.util.analyzeEmotions +import com.ninecraft.booket.core.common.util.EmotionDisplayType +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun EmotionAnalysisResultText( + emotions: ImmutableList, + brandColor: Color, + secondaryColor: Color, + emotionTextStyle: TextStyle, + regularTextStyle: TextStyle, +): AnnotatedString { + val analysisResult = remember(emotions) { analyzeEmotions(emotions) } + + return when (analysisResult.displayType) { + EmotionDisplayType.SINGLE -> { + val emotion = analysisResult.topEmotions.first() + buildAnnotatedString { + withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { + append("이 책에서 ") + } + withStyle(SpanStyle(color = brandColor, fontSize = emotionTextStyle.fontSize, fontWeight = emotionTextStyle.fontWeight)) { + append(emotion.type.displayName) + } + withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { + append(" 감정을 많이 느꼈어요") + } + } + } + + EmotionDisplayType.DUAL -> { + val emotions = analysisResult.topEmotions + buildAnnotatedString { + withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { + append("이 책에서 ") + } + emotions.forEachIndexed { index, emotion -> + if (index > 0) { + withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { + append(", ") + } + } + withStyle(SpanStyle(color = brandColor, fontSize = emotionTextStyle.fontSize, fontWeight = emotionTextStyle.fontWeight)) { + append(emotion.type.displayName) + } + } + withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { + append(" 감정을 많이 느꼈어요") + } + } + } + + EmotionDisplayType.BALANCED -> { + buildAnnotatedString { + withStyle(SpanStyle(color = secondaryColor, fontSize = regularTextStyle.fontSize, fontWeight = regularTextStyle.fontWeight)) { + append("이 책에서 ") + } + withStyle(SpanStyle(color = brandColor, fontSize = emotionTextStyle.fontSize, fontWeight = emotionTextStyle.fontWeight)) { + append("여러 감정이 고르게 담겼어요") + } + } + } + } +} + +@ComponentPreview +@Composable +private fun EmotionTextAllCasesPreview() { + ReedTheme { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = "1개의 감정이 1위인 경우:") + Text( + text = EmotionAnalysisResultText( + emotions = persistentListOf( + EmotionModel(type = Emotion.WARM, count = 5), + EmotionModel(type = Emotion.JOY, count = 2), + ), + brandColor = ReedTheme.colors.contentBrand, + secondaryColor = ReedTheme.colors.contentSecondary, + emotionTextStyle = ReedTheme.typography.label2SemiBold, + regularTextStyle = ReedTheme.typography.label2Regular, + ), + modifier = Modifier.padding(vertical = 8.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "2개의 감정이 공동 1위인 경우:") + Text( + text = EmotionAnalysisResultText( + emotions = persistentListOf( + EmotionModel(type = Emotion.WARM, count = 5), + EmotionModel(type = Emotion.JOY, count = 5), + EmotionModel(type = Emotion.SADNESS, count = 2), + ), + brandColor = ReedTheme.colors.contentBrand, + secondaryColor = ReedTheme.colors.contentSecondary, + emotionTextStyle = ReedTheme.typography.label2SemiBold, + regularTextStyle = ReedTheme.typography.label2Regular, + ), + modifier = Modifier.padding(vertical = 8.dp), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "3~4개의 감정이 공동 1위인 경우:") + Text( + text = EmotionAnalysisResultText( + emotions = persistentListOf( + EmotionModel(type = Emotion.WARM, count = 3), + EmotionModel(type = Emotion.JOY, count = 3), + EmotionModel(type = Emotion.SADNESS, count = 3), + EmotionModel(type = Emotion.TENSION, count = 3), + ), + brandColor = ReedTheme.colors.contentBrand, + secondaryColor = ReedTheme.colors.contentSecondary, + emotionTextStyle = ReedTheme.typography.label2SemiBold, + regularTextStyle = ReedTheme.typography.label2Regular, + ), + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } +} 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 new file mode 100644 index 00000000..e8f9d5a6 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordItem.kt @@ -0,0 +1,103 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.component.ResourceImage +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import com.ninecraft.booket.core.designsystem.R as designR + +@Suppress("unused") +@Composable +internal fun RecordItem( + quote: String, + emotionTags: ImmutableList, + pageNumber: Int, + createdAt: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .clip(RoundedCornerShape(ReedTheme.radius.md)) + .background(ReedTheme.colors.baseSecondary) + .padding( + start = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + bottom = ReedTheme.spacing.spacing4, + ), + ) { + Text( + text = quote, + color = ReedTheme.colors.contentSecondary, + overflow = TextOverflow.Ellipsis, + maxLines = 4, + style = ReedTheme.typography.body2Medium, + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + ResourceImage( + imageRes = designR.drawable.ic_placeholder, + contentDescription = "Emotion Graphic", + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing2)) + Column { + Text( + text = "#감동·공감", + color = ReedTheme.colors.contentBrand, + style = ReedTheme.typography.label1SemiBold, + ) + Text( + text = createdAt, + color = ReedTheme.colors.contentTertiary, + style = ReedTheme.typography.caption1Regular, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${pageNumber}P", + color = ReedTheme.colors.contentBrand, + style = ReedTheme.typography.body2Medium, + ) + } + } +} + +@ComponentPreview +@Composable +private fun RecordItemPreview() { + ReedTheme { + RecordItem( + quote = "", + emotionTags = persistentListOf(), + pageNumber = 12, + createdAt = "2025.06.25", + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordSortBottomSheet.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordSortBottomSheet.kt new file mode 100644 index 00000000..b36da319 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordSortBottomSheet.kt @@ -0,0 +1,151 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.ui.component.ReedBottomSheet +import com.ninecraft.booket.feature.detail.R +import com.ninecraft.booket.feature.detail.book.RecordSort +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import com.ninecraft.booket.core.designsystem.R as designR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RecordSortBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + onCloseButtonClick: () -> Unit, + recordSortItems: ImmutableList, + currentRecordSort: RecordSort, + onItemSelected: (RecordSort) -> Unit, + modifier: Modifier = Modifier, +) { + ReedBottomSheet( + onDismissRequest = { + onDismissRequest() + }, + sheetState = sheetState, + ) { + Column( + modifier = modifier + .padding( + start = ReedTheme.spacing.spacing5, + top = ReedTheme.spacing.spacing5, + end = ReedTheme.spacing.spacing5, + ), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.record_sort_title), + color = ReedTheme.colors.contentPrimary, + textAlign = TextAlign.Center, + style = ReedTheme.typography.heading2SemiBold, + ) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_close), + contentDescription = "Close Icon", + modifier = Modifier.clickable { + onCloseButtonClick() + }, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing5)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + ) { + recordSortItems.forEach { item -> + RecordSortItem( + item = item, + selected = item == currentRecordSort, + onClick = { + if (item != currentRecordSort) { + onItemSelected(item) + } + }, + ) + } + } + } + } +} + +@Composable +fun RecordSortItem( + item: RecordSort, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .selectable( + selected = selected, + indication = null, + role = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + ) + .padding( + horizontal = ReedTheme.spacing.spacing1, + vertical = ReedTheme.spacing.spacing4, + ), + ) { + Text( + text = stringResource(item.getDisplayNameRes()), + color = if (selected) ReedTheme.colors.contentBrand else ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body1Medium, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@ComponentPreview +@Composable +private fun RecordSortBottomSheetPreview() { + val sheetState = SheetState( + skipPartiallyExpanded = true, + initialValue = SheetValue.Expanded, + positionalThreshold = { 0f }, + velocityThreshold = { 0f }, + ) + ReedTheme { + RecordSortBottomSheet( + onDismissRequest = {}, + sheetState = sheetState, + onCloseButtonClick = {}, + recordSortItems = RecordSort.entries.toImmutableList(), + currentRecordSort = RecordSort.PAGE_ASCENDING, + onItemSelected = {}, + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordsCollection.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordsCollection.kt new file mode 100644 index 00000000..ac947615 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordsCollection.kt @@ -0,0 +1,115 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +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.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 androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.feature.detail.R +import com.ninecraft.booket.feature.detail.book.BookDetailUiEvent +import com.ninecraft.booket.feature.detail.book.BookDetailUiState +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +internal fun RecordsCollection( + state: BookDetailUiState, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = ReedTheme.spacing.spacing5) + .then( + if (state.recordCollections.isEmpty()) { + Modifier.height(400.dp) + } else { + // contentPadding + Header + (RecordItem + padding) * size + Modifier.height(36.dp + 40.dp + (192 * state.recordCollections.size).dp) + }, + ), + contentPadding = PaddingValues( + top = ReedTheme.spacing.spacing6, + bottom = ReedTheme.spacing.spacing3, + ), + verticalArrangement = Arrangement.spacedBy(ReedTheme.spacing.spacing3), + userScrollEnabled = false, + ) { + item { + RecordsCollectionHeader(state = state) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + } + + if (state.recordCollections.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(324.dp), // 400.dp - (contentPadding + Header) + 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.recordCollections.size, + key = { index -> state.recordCollections[index].id }, + ) { index -> + val record = state.recordCollections[index] + RecordItem( + quote = record.quote, + emotionTags = record.emotionTags.toImmutableList(), + pageNumber = record.pageNumber, + createdAt = record.createdAt, + modifier = Modifier.clickable { + state.eventSink(BookDetailUiEvent.OnRecordItemClick(record.id)) + }, + ) + } + } + } +} + +@ComponentPreview +@Composable +private fun RecordsCollectionEmptyPreview() { + ReedTheme { + RecordsCollection( + state = BookDetailUiState( + recordCollections = persistentListOf(), + eventSink = {}, + ), + ) + } +} + +@ComponentPreview +@Composable +private fun RecordsCollectionPreview() { + ReedTheme { + RecordsCollection( + state = BookDetailUiState( + eventSink = {}, + ), + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordsCollectionHeader.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordsCollectionHeader.kt new file mode 100644 index 00000000..cc98786f --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/RecordsCollectionHeader.kt @@ -0,0 +1,63 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.feature.detail.R +import com.ninecraft.booket.feature.detail.book.BookDetailUiEvent +import com.ninecraft.booket.feature.detail.book.BookDetailUiState +import com.ninecraft.booket.core.designsystem.R as designR + +@Composable +internal fun RecordsCollectionHeader( + state: BookDetailUiState, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row { + Text( + text = stringResource(R.string.record_collection), + color = ReedTheme.colors.contentPrimary, + style = ReedTheme.typography.headline2SemiBold, + ) + Spacer(modifier = Modifier.width(ReedTheme.spacing.spacing1)) + Text( + text = "${state.recordCollections.size}", + color = ReedTheme.colors.contentBrand, + style = ReedTheme.typography.headline2SemiBold, + ) + } + Row( + modifier = Modifier.clickable { + state.eventSink(BookDetailUiEvent.OnRecordSortButtonClick) + }, + ) { + Text( + text = stringResource(state.currentRecordSort.getDisplayNameRes()), + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.label1Medium, + ) + Icon( + imageVector = ImageVector.vectorResource(designR.drawable.ic_chevron_down), + contentDescription = "Dropdown Icon", + tint = ReedTheme.colors.contentSecondary, + ) + } + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/SeedItem.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/SeedItem.kt new file mode 100644 index 00000000..ef47e7a8 --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/component/SeedItem.kt @@ -0,0 +1,77 @@ +package com.ninecraft.booket.feature.detail.book.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +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.unit.dp +import com.ninecraft.booket.core.common.extensions.toBackgroundColor +import com.ninecraft.booket.core.common.extensions.toTextColor +import com.ninecraft.booket.core.designsystem.ComponentPreview +import com.ninecraft.booket.core.designsystem.component.ResourceImage +import com.ninecraft.booket.core.designsystem.theme.ReedTheme +import com.ninecraft.booket.core.model.Emotion +import com.ninecraft.booket.core.model.EmotionModel +import com.ninecraft.booket.core.designsystem.R as designR + +@Composable +internal fun SeedItem( + emotion: EmotionModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.background(ReedTheme.colors.baseSecondary), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ResourceImage( + imageRes = designR.drawable.ic_placeholder, + contentDescription = "Seed Graphic Image", + modifier = Modifier.size(50.dp), + ) + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing2)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(ReedTheme.radius.full)) + .background(emotion.type.toBackgroundColor()) + .padding( + horizontal = ReedTheme.spacing.spacing2, + vertical = ReedTheme.spacing.spacing1, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = emotion.type.displayName, + color = emotion.type.toTextColor(), + style = ReedTheme.typography.body2Medium, + ) + } + Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1)) + Text( + text = "${emotion.count}", + color = ReedTheme.colors.contentSecondary, + style = ReedTheme.typography.body2Medium, + ) + } +} + +@ComponentPreview +@Composable +private fun SeedItemPreview() { + ReedTheme { + SeedItem( + emotion = EmotionModel( + type = Emotion.WARM, + count = 3, + ), + ) + } +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/HandleReviewDetailSideEffect.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/HandleRecordDetailSideEffect.kt similarity index 71% rename from feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/HandleReviewDetailSideEffect.kt rename to feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/HandleRecordDetailSideEffect.kt index 0685a610..2265e872 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/HandleReviewDetailSideEffect.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/HandleRecordDetailSideEffect.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.detail.review +package com.ninecraft.booket.feature.detail.record import android.widget.Toast import androidx.compose.runtime.Composable @@ -6,14 +6,14 @@ import androidx.compose.ui.platform.LocalContext import com.skydoves.compose.effects.RememberedEffect @Composable -internal fun HandleReviewDetailSideEffects( - state: ReviewDetailUiState, +internal fun HandleRecordDetailSideEffects( + state: RecordDetailUiState, ) { val context = LocalContext.current RememberedEffect(state.sideEffect) { when (state.sideEffect) { - is ReviewDetailSideEffect.ShowToast -> { + is RecordDetailSideEffect.ShowToast -> { Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailPresenter.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt similarity index 61% rename from feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailPresenter.kt rename to feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt index ed4fe94a..eab5cfdc 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailPresenter.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailPresenter.kt @@ -1,11 +1,11 @@ -package com.ninecraft.booket.feature.detail.review +package com.ninecraft.booket.feature.detail.record import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import com.ninecraft.booket.feature.screens.ReviewDetailScreen +import com.ninecraft.booket.feature.screens.RecordDetailScreen import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.Navigator @@ -15,33 +15,33 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.components.ActivityRetainedComponent -class ReviewDetailPresenter @AssistedInject constructor( +class RecordDetailPresenter @AssistedInject constructor( @Assisted private val navigator: Navigator, -) : Presenter { +) : Presenter { @Suppress("unused") @Composable - override fun present(): ReviewDetailUiState { + override fun present(): RecordDetailUiState { val scope = rememberCoroutineScope() - var sideEffect by rememberRetained { mutableStateOf(null) } + var sideEffect by rememberRetained { mutableStateOf(null) } - fun handleEvent(event: ReviewDetailUiEvent) { + fun handleEvent(event: RecordDetailUiEvent) { when (event) { - ReviewDetailUiEvent.OnBackClicked -> { + RecordDetailUiEvent.OnCloseClicked -> { navigator.pop() } } } - return ReviewDetailUiState( + return RecordDetailUiState( sideEffect = sideEffect, eventSink = ::handleEvent, ) } } -@CircuitInject(ReviewDetailScreen::class, ActivityRetainedComponent::class) +@CircuitInject(RecordDetailScreen::class, ActivityRetainedComponent::class) @AssistedFactory fun interface Factory { - fun create(navigator: Navigator): ReviewDetailPresenter + fun create(navigator: Navigator): RecordDetailPresenter } diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailUi.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt similarity index 92% rename from feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailUi.kt rename to feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt index 649c3ace..f7d8af73 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailUi.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUi.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.detail.review +package com.ninecraft.booket.feature.detail.record import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -28,20 +28,20 @@ import com.ninecraft.booket.core.designsystem.theme.White import com.ninecraft.booket.core.ui.component.ReedFullScreen import com.ninecraft.booket.core.ui.component.ReedTopAppBar import com.ninecraft.booket.feature.detail.R -import com.ninecraft.booket.feature.detail.review.component.QuoteBox -import com.ninecraft.booket.feature.detail.review.component.ReviewBox -import com.ninecraft.booket.feature.screens.ReviewDetailScreen +import com.ninecraft.booket.feature.detail.record.component.QuoteBox +import com.ninecraft.booket.feature.detail.record.component.ReviewBox +import com.ninecraft.booket.feature.screens.RecordDetailScreen import com.slack.circuit.codegen.annotations.CircuitInject import dagger.hilt.android.components.ActivityRetainedComponent import com.ninecraft.booket.core.designsystem.R as designR -@CircuitInject(ReviewDetailScreen::class, ActivityRetainedComponent::class) +@CircuitInject(RecordDetailScreen::class, ActivityRetainedComponent::class) @Composable -internal fun ReviewDetail( - state: ReviewDetailUiState, +internal fun RecordDetailUi( + state: RecordDetailUiState, modifier: Modifier = Modifier, ) { - HandleReviewDetailSideEffects( + HandleRecordDetailSideEffects( state = state, ) @@ -51,7 +51,7 @@ internal fun ReviewDetail( startIconRes = designR.drawable.ic_close, startIconDescription = "Close Icon", startIconOnClick = { - state.eventSink(ReviewDetailUiEvent.OnBackClicked) + state.eventSink(RecordDetailUiEvent.OnCloseClicked) }, ) ReviewDetailContent(modifier) @@ -154,8 +154,8 @@ private fun ReviewDetailContent(modifier: Modifier = Modifier) { @Composable private fun ReviewDetailPreview() { ReedTheme { - ReviewDetail( - state = ReviewDetailUiState( + RecordDetailUi( + state = RecordDetailUiState( eventSink = {}, ), ) 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 new file mode 100644 index 00000000..724cec3d --- /dev/null +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/RecordDetailUiState.kt @@ -0,0 +1,23 @@ +package com.ninecraft.booket.feature.detail.record + +import androidx.compose.runtime.Immutable +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import java.util.UUID + +data class RecordDetailUiState( + val sideEffect: RecordDetailSideEffect? = null, + val eventSink: (RecordDetailUiEvent) -> Unit, +) : CircuitUiState + +@Immutable +sealed interface RecordDetailSideEffect { + data class ShowToast( + val message: String, + private val key: String = UUID.randomUUID().toString(), + ) : RecordDetailSideEffect +} + +sealed interface RecordDetailUiEvent : CircuitUiEvent { + data object OnCloseClicked : RecordDetailUiEvent +} diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/component/QuoteBox.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteBox.kt similarity index 96% rename from feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/component/QuoteBox.kt rename to feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteBox.kt index bea0290a..73043ac4 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/component/QuoteBox.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/QuoteBox.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.detail.review.component +package com.ninecraft.booket.feature.detail.record.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/component/ReviewBox.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt similarity index 97% rename from feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/component/ReviewBox.kt rename to feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt index 14a582b2..2d09ef29 100644 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/component/ReviewBox.kt +++ b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/record/component/ReviewBox.kt @@ -1,4 +1,4 @@ -package com.ninecraft.booket.feature.detail.review.component +package com.ninecraft.booket.feature.detail.record.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailUiState.kt b/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailUiState.kt deleted file mode 100644 index 7f6f1e2d..00000000 --- a/feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/review/ReviewDetailUiState.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.ninecraft.booket.feature.detail.review - -import com.slack.circuit.runtime.CircuitUiEvent -import com.slack.circuit.runtime.CircuitUiState -import java.util.UUID - -data class ReviewDetailUiState( - val sideEffect: ReviewDetailSideEffect? = null, - val eventSink: (ReviewDetailUiEvent) -> Unit, -) : CircuitUiState - -sealed interface ReviewDetailSideEffect { - data class ShowToast( - val message: String, - private val key: String = UUID.randomUUID().toString(), - ) : ReviewDetailSideEffect -} - -sealed interface ReviewDetailUiEvent : CircuitUiEvent { - data object OnBackClicked : ReviewDetailUiEvent -} diff --git a/feature/detail/src/main/res/values/strings.xml b/feature/detail/src/main/res/values/strings.xml index bee2ba3e..f3a7fcfb 100644 --- a/feature/detail/src/main/res/values/strings.xml +++ b/feature/detail/src/main/res/values/strings.xml @@ -3,4 +3,10 @@ 독서 기록 수집한 문장 감상평 기록 + 도서 상태 + 변경하기 + 정렬 + 내 기록 모음 + 내가 모은 씨앗 + 첫 기록을 남겨 보세요!\n나만의 아카이브를 만들 수 있어요. 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 1dc28bf4..a029bb63 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 @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.library +import androidx.compose.runtime.Immutable import com.ninecraft.booket.core.model.LibraryBookSummaryModel import com.ninecraft.booket.core.ui.component.FooterState import com.slack.circuit.runtime.CircuitUiEvent @@ -26,6 +27,7 @@ data class LibraryUiState( val eventSink: (LibraryUiEvent) -> Unit, ) : CircuitUiState +@Immutable sealed interface LibrarySideEffect { data class ShowToast(val message: String) : LibrarySideEffect } diff --git a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryBookItem.kt b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryBookItem.kt index dde031d0..19bafcd0 100644 --- a/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryBookItem.kt +++ b/feature/library/src/main/kotlin/com/ninecraft/booket/feature/library/component/LibraryBookItem.kt @@ -99,7 +99,7 @@ fun LibraryBookItem( ) Spacer(Modifier.width(ReedTheme.spacing.spacing1)) Text( - text = book.recordCount.toString(), + text = "${book.recordCount}", color = ReedTheme.colors.contentBrand, style = ReedTheme.typography.label2SemiBold, ) diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt index 88f0073e..fea02984 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/login/LoginUiState.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.login +import androidx.compose.runtime.Immutable import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.util.UUID @@ -10,6 +11,7 @@ data class LoginUiState( val eventSink: (LoginUiEvent) -> Unit, ) : CircuitUiState +@Immutable sealed interface LoginSideEffect { data object KakaoLogin : LoginSideEffect data class ShowToast( diff --git a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUiState.kt b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUiState.kt index 7f8e2848..64c72d49 100644 --- a/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUiState.kt +++ b/feature/login/src/main/kotlin/com/ninecraft/booket/feature/termsagreement/TermsAgreementUiState.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.termsagreement +import androidx.compose.runtime.Immutable import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import kotlinx.collections.immutable.ImmutableList @@ -12,6 +13,7 @@ data class TermsAgreementUiState( val eventSink: (TermsAgreementUiEvent) -> Unit, ) : CircuitUiState +@Immutable sealed interface TermsAgreementSideEffect { data class ShowToast( val message: String, 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 9ca4a4e5..ca7f9d15 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 @@ -15,8 +15,8 @@ import com.ninecraft.booket.core.designsystem.EmotionTag import com.ninecraft.booket.core.designsystem.RecordStep import com.ninecraft.booket.feature.screens.LoginScreen import com.ninecraft.booket.feature.screens.OcrScreen +import com.ninecraft.booket.feature.screens.RecordDetailScreen import com.ninecraft.booket.feature.screens.RecordScreen -import com.ninecraft.booket.feature.screens.ReviewDetailScreen import com.orhanobut.logger.Logger import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.rememberAnsweringNavigator @@ -208,7 +208,7 @@ class RecordRegisterPresenter @AssistedInject constructor( is RecordRegisterUiEvent.OnRecordSavedDialogConfirm -> { isRecordSavedDialogVisible = false navigator.pop() - navigator.goTo(ReviewDetailScreen) + navigator.goTo(RecordDetailScreen(event.recordId)) } is RecordRegisterUiEvent.OnRecordSavedDialogDismiss -> { diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt index 02f245ed..a9b2d3c1 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUi.kt @@ -107,7 +107,7 @@ internal fun RecordRegister( confirmButtonText = stringResource(R.string.record_saved_dialog_move_to_detail), dismissButtonText = stringResource(R.string.record_saved_dialog_close), onConfirmRequest = { - state.eventSink(RecordRegisterUiEvent.OnRecordSavedDialogConfirm) + state.eventSink(RecordRegisterUiEvent.OnRecordSavedDialogConfirm("")) }, onDismissRequest = { state.eventSink(RecordRegisterUiEvent.OnRecordSavedDialogDismiss) diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt index dd7c506c..f4084797 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/register/RecordRegisterUiState.kt @@ -1,6 +1,7 @@ package com.ninecraft.booket.feature.record.register import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable import com.ninecraft.booket.core.designsystem.EmotionTag import com.ninecraft.booket.core.designsystem.RecordStep import com.slack.circuit.runtime.CircuitUiEvent @@ -26,6 +27,7 @@ data class RecordRegisterUiState( val eventSink: (RecordRegisterUiEvent) -> Unit, ) : CircuitUiState +@Immutable sealed interface RecordRegisterSideEffect { data class ShowToast( val message: String, @@ -45,6 +47,6 @@ sealed interface RecordRegisterUiEvent : CircuitUiEvent { data object OnSelectionConfirmed : RecordRegisterUiEvent data object OnExitDialogConfirm : RecordRegisterUiEvent data object OnExitDialogDismiss : RecordRegisterUiEvent - data object OnRecordSavedDialogConfirm : RecordRegisterUiEvent + data class OnRecordSavedDialogConfirm(val recordId: String) : RecordRegisterUiEvent data object OnRecordSavedDialogDismiss : RecordRegisterUiEvent } diff --git a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt index 9129fbba..19ca1ce2 100644 --- a/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt +++ b/feature/screens/src/main/kotlin/com/ninecraft/booket/feature/screens/Screens.kt @@ -42,7 +42,7 @@ data object OcrScreen : ReedScreen(name = "Ocr()") { } @Parcelize -data object ReviewDetailScreen : ReedScreen(name = "ReviewDetail()") +data class RecordDetailScreen(val recordId: String) : ReedScreen(name = "RecordDetail()") @Parcelize data class WebViewScreen( diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchPresenter.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchPresenter.kt index 35a2bc86..39c97561 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchPresenter.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchPresenter.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.mutableIntStateOf 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.utils.handleException import com.ninecraft.booket.core.data.api.repository.BookRepository import com.ninecraft.booket.core.model.BookSearchModel diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUi.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUi.kt index b5e2e079..3f51459a 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUi.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUi.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.designsystem.DevicePreview import com.ninecraft.booket.core.designsystem.component.textfield.ReedTextField import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUiState.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUiState.kt index 18846fbc..d5cc342d 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUiState.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/SearchUiState.kt @@ -1,6 +1,8 @@ package com.ninecraft.booket.feature.search import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Immutable +import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.model.BookSearchModel import com.ninecraft.booket.core.model.BookSummaryModel import com.ninecraft.booket.core.ui.component.FooterState @@ -36,6 +38,7 @@ data class SearchUiState( val isEmptySearchResult: Boolean get() = uiState is UiState.Success && searchResult.totalResults == 0 } +@Immutable sealed interface SearchSideEffect { data class ShowToast( val message: String, @@ -59,24 +62,3 @@ sealed interface SearchUiEvent : CircuitUiEvent { data object OnBookRegisterSuccessOkButtonClick : SearchUiEvent data object OnBookRegisterSuccessCancelButtonClick : SearchUiEvent } - -enum class BookStatus(val value: String) { - BEFORE_READING("BEFORE_READING"), - READING("READING"), - COMPLETED("COMPLETED"), - ; - - fun getDisplayNameRes(): Int { - return when (this) { - BEFORE_READING -> R.string.book_status_before - READING -> R.string.book_status_reading - COMPLETED -> R.string.book_status_completed - } - } - - companion object Companion { - fun fromValue(value: String): BookStatus? { - return entries.find { it.value == value } - } - } -} diff --git a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterBottomSheet.kt b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterBottomSheet.kt index 2ca570c9..e7f2ab43 100644 --- a/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterBottomSheet.kt +++ b/feature/search/src/main/kotlin/com/ninecraft/booket/feature/search/component/BookRegisterBottomSheet.kt @@ -28,13 +28,13 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign +import com.ninecraft.booket.core.common.constants.BookStatus import com.ninecraft.booket.core.designsystem.ComponentPreview import com.ninecraft.booket.core.ui.component.ReedBottomSheet 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.feature.search.BookStatus import com.ninecraft.booket.feature.search.R import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index d7a7f3b4..7bb8bbee 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -13,8 +13,5 @@ 아니요, 나중에요 네, 진행할게요! 도서 등록 - 읽기 전 - 읽는 중 - 독서 완료 최근 검색어 내역이 없습니다. diff --git a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt index eeca66cd..21d5a76b 100644 --- a/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt +++ b/feature/settings/src/main/kotlin/com/ninecraft/booket/feature/settings/SettingsUiState.kt @@ -1,5 +1,6 @@ package com.ninecraft.booket.feature.settings +import androidx.compose.runtime.Immutable import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import java.util.UUID @@ -13,6 +14,7 @@ data class SettingsUiState( val eventSink: (SettingsUiEvent) -> Unit, ) : CircuitUiState +@Immutable sealed interface SettingsSideEffect { data class ShowToast( val message: String, diff --git a/gradle.properties b/gradle.properties index 132244e5..4f2bc371 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,7 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true + +# Compose Compiler Metrics and Reports +enableComposeCompilerMetrics=true +enableComposeCompilerReports=true