Skip to content

Commit 2a3ba75

Browse files
committed
[BOOK-212] feat: 도서 상세 화면 도서 기록 모음 무한 스크롤 구현
정상 동작 확인 필요 및 최적화 필요
1 parent 4384ff5 commit 2a3ba75

File tree

6 files changed

+160
-103
lines changed

6 files changed

+160
-103
lines changed

feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailPresenter.kt

Lines changed: 106 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@ package com.ninecraft.booket.feature.detail.book
33
import androidx.compose.runtime.Composable
44
import androidx.compose.runtime.LaunchedEffect
55
import androidx.compose.runtime.getValue
6+
import androidx.compose.runtime.mutableIntStateOf
67
import androidx.compose.runtime.mutableStateOf
78
import androidx.compose.runtime.rememberCoroutineScope
89
import androidx.compose.runtime.setValue
910
import com.ninecraft.booket.core.common.constants.BookStatus
1011
import com.ninecraft.booket.core.common.utils.handleException
1112
import com.ninecraft.booket.core.data.api.repository.BookRepository
13+
import com.ninecraft.booket.core.data.api.repository.RecordRepository
1214
import com.ninecraft.booket.core.model.BookDetailModel
1315
import com.ninecraft.booket.core.model.EmotionModel
16+
import com.ninecraft.booket.core.model.ReadingRecordModel
17+
import com.ninecraft.booket.core.ui.component.FooterState
1418
import com.ninecraft.booket.feature.screens.BookDetailScreen
1519
import com.ninecraft.booket.feature.screens.LoginScreen
1620
import com.ninecraft.booket.feature.screens.RecordDetailScreen
@@ -27,86 +31,86 @@ import dagger.hilt.android.components.ActivityRetainedComponent
2731
import kotlinx.collections.immutable.ImmutableList
2832
import kotlinx.collections.immutable.persistentListOf
2933
import kotlinx.collections.immutable.toImmutableList
34+
import kotlinx.collections.immutable.toPersistentList
3035
import kotlinx.coroutines.launch
3136

3237
class BookDetailPresenter @AssistedInject constructor(
3338
@Assisted private val screen: BookDetailScreen,
3439
@Assisted private val navigator: Navigator,
35-
private val repository: BookRepository,
40+
private val bookRepository: BookRepository,
41+
private val recordRepository: RecordRepository,
3642
) : Presenter<BookDetailUiState> {
43+
companion object {
44+
private const val PAGE_SIZE = 20
45+
private const val START_INDEX = 1
46+
}
3747

3848
@Composable
3949
override fun present(): BookDetailUiState {
4050
val scope = rememberCoroutineScope()
41-
42-
var isLoading by rememberRetained { mutableStateOf(false) }
51+
var uiState by rememberRetained { mutableStateOf<UiState>(UiState.Idle) }
52+
var footerState by rememberRetained { mutableStateOf<FooterState>(FooterState.Idle) }
4353
var bookDetail by rememberRetained { mutableStateOf(BookDetailModel()) }
44-
var seedsStates by rememberRetained { mutableStateOf<ImmutableList<EmotionModel>>(persistentListOf<EmotionModel>()) }
54+
var seedsStates by rememberRetained { mutableStateOf<ImmutableList<EmotionModel>>(persistentListOf()) }
55+
var readingRecords by rememberRetained { mutableStateOf(persistentListOf<ReadingRecordModel>()) }
56+
var currentStartIndex by rememberRetained { mutableIntStateOf(START_INDEX) }
57+
var isLastPage by rememberRetained { mutableStateOf(false) }
58+
var currentBookStatus by rememberRetained { mutableStateOf(BookStatus.READING) }
59+
var currentRecordSort by rememberRetained { mutableStateOf(RecordSort.PAGE_NUMBER_ASC) }
4560
var isBookUpdateBottomSheetVisible by rememberRetained { mutableStateOf(false) }
4661
var isRecordSortBottomSheetVisible by rememberRetained { mutableStateOf(false) }
47-
var currentBookStatus by rememberRetained { mutableStateOf(BookStatus.READING) }
48-
var currentRecordSort by rememberRetained { mutableStateOf(RecordSort.PAGE_ASCENDING) }
4962
var sideEffect by rememberRetained { mutableStateOf<BookDetailSideEffect?>(null) }
5063

5164
fun getSeedsStats() {
5265
scope.launch {
53-
try {
54-
isLoading = true
55-
repository.getSeedsStats(screen.userBookId)
56-
.onSuccess { result ->
57-
seedsStates = result.categories.toImmutableList()
58-
}
59-
.onFailure { exception ->
60-
val handleErrorMessage = { message: String ->
61-
Logger.e(message)
62-
sideEffect = BookDetailSideEffect.ShowToast(message)
63-
}
64-
65-
handleException(
66-
exception = exception,
67-
onError = handleErrorMessage,
68-
onLoginRequired = {
69-
navigator.resetRoot(LoginScreen)
70-
},
71-
)
66+
bookRepository.getSeedsStats(screen.userBookId)
67+
.onSuccess { result ->
68+
seedsStates = result.categories.toImmutableList()
69+
}
70+
.onFailure { exception ->
71+
val handleErrorMessage = { message: String ->
72+
Logger.e(message)
73+
sideEffect = BookDetailSideEffect.ShowToast(message)
7274
}
73-
} finally {
74-
isLoading = false
75-
}
75+
76+
handleException(
77+
exception = exception,
78+
onError = handleErrorMessage,
79+
onLoginRequired = {
80+
navigator.resetRoot(LoginScreen)
81+
},
82+
)
83+
}
7684
}
7785
}
7886

7987
fun getBookDetail() {
8088
scope.launch {
81-
try {
82-
isLoading = true
83-
repository.getBookDetail(screen.isbn)
84-
.onSuccess { result ->
85-
bookDetail = result
86-
}
87-
.onFailure { exception ->
88-
val handleErrorMessage = { message: String ->
89-
Logger.e(message)
90-
sideEffect = BookDetailSideEffect.ShowToast(message)
91-
}
92-
93-
handleException(
94-
exception = exception,
95-
onError = handleErrorMessage,
96-
onLoginRequired = {
97-
navigator.resetRoot(LoginScreen)
98-
},
99-
)
89+
bookRepository.getBookDetail(screen.isbn)
90+
.onSuccess { result ->
91+
bookDetail = result
92+
}
93+
.onFailure { exception ->
94+
val handleErrorMessage = { message: String ->
95+
Logger.e(message)
96+
sideEffect = BookDetailSideEffect.ShowToast(message)
10097
}
101-
} finally {
102-
isLoading = false
103-
}
98+
99+
handleException(
100+
exception = exception,
101+
onError = handleErrorMessage,
102+
onLoginRequired = {
103+
navigator.resetRoot(LoginScreen)
104+
},
105+
)
106+
}
107+
104108
}
105109
}
106110

107111
fun upsertBook(bookIsbn: String, bookStatus: String) {
108112
scope.launch {
109-
repository.upsertBook(bookIsbn, bookStatus)
113+
bookRepository.upsertBook(bookIsbn, bookStatus)
110114
.onSuccess {
111115
isBookUpdateBottomSheetVisible = false
112116
}
@@ -127,9 +131,50 @@ class BookDetailPresenter @AssistedInject constructor(
127131
}
128132
}
129133

134+
fun getReadingRecords(startIndex: Int = START_INDEX) {
135+
scope.launch {
136+
if (startIndex == START_INDEX) {
137+
uiState = UiState.Loading
138+
} else {
139+
footerState = FooterState.Loading
140+
}
141+
142+
recordRepository.getReadingRecords(
143+
userBookId = screen.userBookId,
144+
sort = currentRecordSort.value,
145+
page = START_INDEX,
146+
size = PAGE_SIZE,
147+
).onSuccess { result ->
148+
readingRecords = if (startIndex == START_INDEX) {
149+
result.content.toPersistentList()
150+
} else {
151+
(readingRecords + result.content).toPersistentList()
152+
}
153+
154+
currentStartIndex = startIndex
155+
isLastPage = result.content.size < PAGE_SIZE
156+
157+
if (startIndex == START_INDEX) {
158+
uiState = UiState.Success
159+
} else {
160+
footerState = if (isLastPage) FooterState.End else FooterState.Idle
161+
}
162+
}.onFailure { exception ->
163+
Logger.d(exception)
164+
val errorMessage = exception.message ?: "알 수 없는 오류가 발생했습니다."
165+
if (startIndex == START_INDEX) {
166+
uiState = UiState.Error(errorMessage)
167+
} else {
168+
footerState = FooterState.Error(errorMessage)
169+
}
170+
}
171+
}
172+
}
173+
130174
LaunchedEffect(Unit) {
131175
getSeedsStats()
132176
getBookDetail()
177+
getReadingRecords()
133178
}
134179

135180
fun handleEvent(event: BookDetailUiEvent) {
@@ -178,13 +223,22 @@ class BookDetailPresenter @AssistedInject constructor(
178223
is BookDetailUiEvent.OnRecordItemClick -> {
179224
navigator.goTo(RecordDetailScreen(event.recordId))
180225
}
226+
227+
is BookDetailUiEvent.OnLoadMore -> {
228+
if (footerState !is FooterState.Loading && !isLastPage) {
229+
getReadingRecords(startIndex = currentStartIndex + 1)
230+
}
231+
}
181232
}
182233
}
183234

184235
return BookDetailUiState(
185-
isLoading = isLoading,
236+
uiState = uiState,
237+
footerState = footerState,
186238
bookDetail = bookDetail,
187239
seedsStats = seedsStates,
240+
readingRecords = readingRecords,
241+
currentStartIndex = currentStartIndex,
188242
isBookUpdateBottomSheetVisible = isBookUpdateBottomSheetVisible,
189243
isRecordSortBottomSheetVisible = isRecordSortBottomSheetVisible,
190244
currentBookStatus = currentBookStatus,

feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUi.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import com.ninecraft.booket.core.designsystem.component.button.ReedButtonColorSt
3535
import com.ninecraft.booket.core.designsystem.component.button.largeButtonStyle
3636
import com.ninecraft.booket.core.designsystem.theme.ReedTheme
3737
import com.ninecraft.booket.core.ui.component.InfinityLazyColumn
38+
import com.ninecraft.booket.core.ui.component.LoadStateFooter
3839
import com.ninecraft.booket.core.ui.component.ReedBackTopAppBar
3940
import com.ninecraft.booket.core.ui.component.ReedFullScreen
4041
import com.ninecraft.booket.feature.detail.R
@@ -43,7 +44,7 @@ import com.ninecraft.booket.feature.detail.book.component.BookUpdateBottomSheet
4344
import com.ninecraft.booket.feature.detail.book.component.CollectedSeeds
4445
import com.ninecraft.booket.feature.detail.book.component.RecordItem
4546
import com.ninecraft.booket.feature.detail.book.component.RecordSortBottomSheet
46-
import com.ninecraft.booket.feature.detail.book.component.RecordsCollectionHeader
47+
import com.ninecraft.booket.feature.detail.book.component.ReadingRecordsHeader
4748
import com.ninecraft.booket.feature.screens.BookDetailScreen
4849
import com.slack.circuit.codegen.annotations.CircuitInject
4950
import dagger.hilt.android.components.ActivityRetainedComponent
@@ -131,7 +132,7 @@ internal fun BookDetailContent(
131132
modifier = modifier.fillMaxSize(),
132133
state = lazyListState,
133134
loadMore = {
134-
// TODO: 페이지네이션 로직 추가
135+
state.eventSink(BookDetailUiEvent.OnLoadMore)
135136
},
136137
) {
137138
item {
@@ -192,12 +193,18 @@ internal fun BookDetailContent(
192193
modifier = Modifier.padding(horizontal = ReedTheme.spacing.spacing5),
193194
) {
194195
Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing6))
195-
RecordsCollectionHeader(state = state)
196+
ReadingRecordsHeader(
197+
readingRecords = state.readingRecords,
198+
currentRecordSort = state.currentRecordSort,
199+
onReadingRecordClick = {
200+
state.eventSink(BookDetailUiEvent.OnRecordSortButtonClick)
201+
}
202+
)
196203
Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing1))
197204
}
198205
}
199206

200-
if (state.recordCollections.isEmpty()) {
207+
if (state.readingRecords.isEmpty()) {
201208
item {
202209
Box(
203210
modifier = Modifier
@@ -216,10 +223,10 @@ internal fun BookDetailContent(
216223
}
217224
} else {
218225
items(
219-
count = state.recordCollections.size,
220-
key = { index -> state.recordCollections[index].id },
226+
count = state.readingRecords.size,
227+
key = { index -> state.readingRecords[index].id },
221228
) { index ->
222-
val record = state.recordCollections[index]
229+
val record = state.readingRecords[index]
223230
RecordItem(
224231
quote = record.quote,
225232
emotionTags = record.emotionTags.toImmutableList(),
@@ -236,6 +243,13 @@ internal fun BookDetailContent(
236243
},
237244
)
238245
}
246+
247+
item {
248+
LoadStateFooter(
249+
footerState = state.footerState,
250+
onRetryClick = { state.eventSink(BookDetailUiEvent.OnLoadMore) },
251+
)
252+
}
239253
}
240254
}
241255
}

feature/detail/src/main/kotlin/com/ninecraft/booket/feature/detail/book/BookDetailUiState.kt

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,33 @@ import com.ninecraft.booket.core.common.R
55
import com.ninecraft.booket.core.common.constants.BookStatus
66
import com.ninecraft.booket.core.model.BookDetailModel
77
import com.ninecraft.booket.core.model.EmotionModel
8-
import com.ninecraft.booket.core.model.RecordRegisterModel
8+
import com.ninecraft.booket.core.model.ReadingRecordModel
9+
import com.ninecraft.booket.core.ui.component.FooterState
910
import com.slack.circuit.runtime.CircuitUiEvent
1011
import com.slack.circuit.runtime.CircuitUiState
1112
import kotlinx.collections.immutable.ImmutableList
1213
import kotlinx.collections.immutable.persistentListOf
1314
import java.util.UUID
1415

16+
sealed interface UiState {
17+
data object Idle : UiState
18+
data object Loading : UiState
19+
data object Success : UiState
20+
data class Error(val message: String) : UiState
21+
}
22+
1523
data class BookDetailUiState(
24+
val uiState: UiState = UiState.Idle,
25+
val footerState: FooterState = FooterState.Idle,
1626
val isLoading: Boolean = false,
1727
val bookDetail: BookDetailModel = BookDetailModel(),
1828
val seedsStats: ImmutableList<EmotionModel> = persistentListOf(),
29+
val readingRecords: ImmutableList<ReadingRecordModel> = persistentListOf(),
30+
val currentStartIndex: Int = 1,
31+
val currentBookStatus: BookStatus = BookStatus.BEFORE_READING,
32+
val currentRecordSort: RecordSort = RecordSort.PAGE_NUMBER_ASC,
1933
val isBookUpdateBottomSheetVisible: Boolean = false,
2034
val isRecordSortBottomSheetVisible: Boolean = false,
21-
val currentBookStatus: BookStatus = BookStatus.BEFORE_READING,
22-
val currentRecordSort: RecordSort = RecordSort.PAGE_ASCENDING,
23-
val recordCollections: ImmutableList<RecordRegisterModel> = persistentListOf(
24-
RecordRegisterModel(
25-
id = "0",
26-
pageNumber = 12,
27-
quote = "책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.",
28-
createdAt = "2025.06.25",
29-
),
30-
RecordRegisterModel(
31-
id = "1",
32-
pageNumber = 13,
33-
quote = "책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.",
34-
createdAt = "2025.06.25",
35-
),
36-
RecordRegisterModel(
37-
id = "2",
38-
pageNumber = 14,
39-
quote = "책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.",
40-
createdAt = "2025.06.25",
41-
),
42-
RecordRegisterModel(
43-
id = "3",
44-
pageNumber = 15,
45-
quote = "책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.책을 읽으면 차분해지며 숲으로 둘러싸인 여름 별장 속으로 간 것 같은 기분이 든다. 그 곳에서 그들이 품은 건축에 대한 이상과 삶을 구경하는 것만으로도 충분했다.",
46-
createdAt = "2025.06.25",
47-
),
48-
),
4935
val sideEffect: BookDetailSideEffect? = null,
5036
val eventSink: (BookDetailUiEvent) -> Unit,
5137
) : CircuitUiState {
@@ -75,17 +61,19 @@ sealed interface BookDetailUiEvent : CircuitUiEvent {
7561
data object OnRecordSortBottomSheetDismiss : BookDetailUiEvent
7662
data class OnRecordSortItemSelected(val sortType: RecordSort) : BookDetailUiEvent
7763
data class OnRecordItemClick(val recordId: String) : BookDetailUiEvent
64+
data object OnLoadMore : BookDetailUiEvent
65+
7866
}
7967

8068
enum class RecordSort(val value: String) {
81-
PAGE_ASCENDING("PAGE_ASCENDING"),
82-
RECENT_REGISTER("RECENT_REGISTER"),
69+
PAGE_NUMBER_ASC("PAGE_NUMBER_ASC"),
70+
CREATED_DATE_DESC("CREATED_DATE_DESC"),
8371
;
8472

8573
fun getDisplayNameRes(): Int {
8674
return when (this) {
87-
PAGE_ASCENDING -> R.string.record_sort_page_ascending
88-
RECENT_REGISTER -> R.string.record_sort_recent_register
75+
PAGE_NUMBER_ASC -> R.string.record_sort_page_ascending
76+
CREATED_DATE_DESC -> R.string.record_sort_recent_register
8977
}
9078
}
9179

0 commit comments

Comments
 (0)