diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt new file mode 100644 index 00000000..9a10de52 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentScreen.kt @@ -0,0 +1,342 @@ +package com.flint.presentation.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flint.R +import com.flint.core.common.util.UiState +import com.flint.core.designsystem.component.indicator.FlintLoadingIndicator +import com.flint.core.designsystem.component.modal.OneButtonModal +import com.flint.core.designsystem.component.textfield.FlintSearchTextField +import com.flint.core.designsystem.component.topappbar.FlintBackTopAppbar +import com.flint.core.designsystem.component.view.FlintSearchEmptyView +import com.flint.core.designsystem.theme.FlintTheme +import com.flint.domain.model.content.BookmarkedContentItemModel +import com.flint.domain.model.content.BookmarkedContentListModel +import com.flint.domain.type.OttType +import com.flint.presentation.profile.component.CollectionCreateContentBookmark +import com.flint.presentation.profile.uistate.SavedContentUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun SavedContentRoute( + paddingValues: PaddingValues, + navigateUp: () -> Unit, + viewModel: SavedContentViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + SavedContentScreen( + uiState = uiState, + navigateUp = navigateUp, + onSearchKeywordChanged = viewModel::updateSearchKeyword, + onClearSearch = viewModel::clearSearchKeyword, + onBookmarkClick = viewModel::toggleBookmark, + onDismissRestrictionModal = viewModel::dismissBookmarkRestrictionModal, + modifier = Modifier.padding(paddingValues), + ) +} + +@Composable +fun SavedContentScreen( + uiState: SavedContentUiState, + navigateUp: () -> Unit, + onSearchKeywordChanged: (String) -> Unit, + onClearSearch: () -> Unit, + onBookmarkClick: (contentId: String) -> Unit, + onDismissRestrictionModal: () -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + + Column( + modifier = modifier + .fillMaxSize() + .background(color = FlintTheme.colors.background), + ) { + FlintBackTopAppbar( + onClick = navigateUp, + title = "저장 작품", + ) + + Spacer(modifier = Modifier.height(12.dp)) + + FlintSearchTextField( + value = uiState.searchKeyword, + onValueChanged = onSearchKeywordChanged, + placeholder = "작품을 검색해보세요", + modifier = Modifier.padding(horizontal = 16.dp), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + }, + ), + onClearAction = onClearSearch, + ) + + // "총 n개"는 실제 데이터가 있는 Success 상태에서만 노출 + if (uiState.contents is UiState.Success) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "총 ${uiState.totalCount}개", + modifier = Modifier.padding(horizontal = 16.dp), + color = FlintTheme.colors.gray100, + style = FlintTheme.typography.body2R14, + ) + + Spacer(modifier = Modifier.height(10.dp)) + } + + when (uiState.contents) { + is UiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + FlintLoadingIndicator() + } + } + is UiState.Success -> { + if (uiState.filteredContents.isEmpty()) { + // 검색 결과가 없을 때 + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + FlintSearchEmptyView( + title = "작품을 찾을 수 없어요", + ) + } + } else { + SavedContentList( + contents = uiState.filteredContents, + onBookmarkClick = onBookmarkClick, + ) + } + } + is UiState.Empty, + is UiState.Failure -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + FlintSearchEmptyView( + title = "작품을 찾을 수 없어요", + ) + } + } + } + } + + // 저장 취소 제한 안내 모달 (저장 작품이 5개일 때 북마크 토글 시 노출) + if (uiState.showBookmarkRestrictionModal) { + OneButtonModal( + title = "작품 저장을 취소할 수 없어요", + message = "취향 키워드 분석을 위해\n최소 ${SavedContentUiState.MIN_REQUIRED_COUNT}개의 작품을 저장해주세요", + buttonText = "확인", + onConfirm = onDismissRestrictionModal, + onDismiss = onDismissRestrictionModal, + icon = R.drawable.ic_gradient_bookmark, + ) + } +} + +@Composable +private fun SavedContentList( + contents: ImmutableList, + onBookmarkClick: (contentId: String) -> Unit, + modifier: Modifier = Modifier, +) { + if (contents.isEmpty()) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + FlintSearchEmptyView( + title = "작품을 찾을 수 없어요", + ) + } + return + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + ) { + items( + items = contents, + key = { it.id }, + ) { content -> + CollectionCreateContentBookmark( + onBookmarkClick = { onBookmarkClick(content.id) }, + onMoreClick = {}, + isBookmarked = true, + bookmarkCount = 123, + imageUrl = content.imageUrl, + title = content.title, + director = "감독이름", + createdYear = content.year, + ottList = content.getOttSimpleList, + ) + } + } +} + +private object SavedContentPreviewData { + val FakeList: ImmutableList = persistentListOf( + BookmarkedContentItemModel( + id = "0", + title = "은하수를 여행하는 히치하이커를 위한 안내서", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf( + OttType.Netflix, + OttType.Disney, + OttType.Tving, + ), + ), + BookmarkedContentItemModel( + id = "1", + title = "해리포터와 불의잔", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf( + OttType.Netflix, + OttType.CoupangPlay + ), + ), + BookmarkedContentItemModel( + id = "2", + title = "해리포터와 불의잔", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf(OttType.Netflix), + ), + BookmarkedContentItemModel( + id = "3", + title = "해리포터와 불의잔", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf(OttType.Netflix), + ), + BookmarkedContentItemModel( + id = "4", + title = "해리포터와 불의잔", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf(OttType.Netflix), + ), + BookmarkedContentItemModel( + id = "5", + title = "해리포터와 불의잔", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf(OttType.Netflix), + ), + BookmarkedContentItemModel( + id = "6", + title = "해리포터와 불의잔", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf(OttType.Netflix), + ), + BookmarkedContentItemModel( + id = "7", + title = "해리포터와 불의잔", + year = 2005, + imageUrl = "", + getOttSimpleList = listOf(OttType.Netflix), + ), + ) +} + +@Preview(showBackground = true) +@Composable +private fun SavedContentScreenPreview() { + FlintTheme { + SavedContentScreen( + uiState = SavedContentUiState( + contents = UiState.Success( + BookmarkedContentListModel(contents = SavedContentPreviewData.FakeList), + ), + ), + navigateUp = {}, + onSearchKeywordChanged = {}, + onClearSearch = {}, + onBookmarkClick = {}, + onDismissRestrictionModal = {}, + ) + } +} + +@Preview(showBackground = true, name = "Empty") +@Composable +private fun SavedContentScreenEmptyPreview() { + FlintTheme { + SavedContentScreen( + uiState = SavedContentUiState(contents = UiState.Empty), + navigateUp = {}, + onSearchKeywordChanged = {}, + onClearSearch = {}, + onBookmarkClick = {}, + onDismissRestrictionModal = {}, + ) + } +} + +@Preview(showBackground = true, name = "Loading") +@Composable +private fun SavedContentScreenLoadingPreview() { + FlintTheme { + SavedContentScreen( + uiState = SavedContentUiState(contents = UiState.Loading), + navigateUp = {}, + onSearchKeywordChanged = {}, + onClearSearch = {}, + onBookmarkClick = {}, + onDismissRestrictionModal = {}, + ) + } +} + +@Preview(showBackground = true, name = "Restriction Modal") +@Composable +private fun SavedContentScreenRestrictionModalPreview() { + FlintTheme { + SavedContentScreen( + uiState = SavedContentUiState( + contents = UiState.Success( + BookmarkedContentListModel(contents = SavedContentPreviewData.FakeList), + ), + showBookmarkRestrictionModal = true, + ), + navigateUp = {}, + onSearchKeywordChanged = {}, + onClearSearch = {}, + onBookmarkClick = {}, + onDismissRestrictionModal = {}, + ) + } +} diff --git a/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt new file mode 100644 index 00000000..52813265 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/profile/SavedContentViewModel.kt @@ -0,0 +1,82 @@ +package com.flint.presentation.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flint.core.common.util.UiState +import com.flint.domain.repository.ContentRepository +import com.flint.presentation.profile.uistate.SavedContentUiState +import com.flint.presentation.profile.uistate.SavedContentUiState.Companion.MIN_REQUIRED_COUNT +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SavedContentViewModel @Inject constructor( + private val contentRepository: ContentRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SavedContentUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadBookmarkedContents() + } + + + // 사용자 저장한 작품 목록 호출 + fun loadBookmarkedContents() { + viewModelScope.launch { + _uiState.update { it.copy(contents = UiState.Loading) } + + contentRepository.getBookmarkedContentList() + .onSuccess { list -> + _uiState.update { + it.copy( + contents = if (list.contents.isEmpty()) { + UiState.Empty + } else { + UiState.Success(list) + }, + ) + } + } + .onFailure { throwable -> + _uiState.update { it.copy(contents = UiState.Failure) } + Timber.e(throwable) + } + } + } + + + fun updateSearchKeyword(keyword: String) { + _uiState.update { it.copy(searchKeyword = keyword) } + } + + // 검색어 초기화 + fun clearSearchKeyword() { + _uiState.update { it.copy(searchKeyword = "") } + } + + + // 북마크 토글 (저장 취소) + // 저장된 작품이 MIN_REQUIRED_COUNT(5)개일 때는 취소를 막고 안내 모달을 노출한다. + fun toggleBookmark(contentId: String) { + val currentCount = uiState.value.totalCount + if (currentCount <= MIN_REQUIRED_COUNT) { + _uiState.update { it.copy(showBookmarkRestrictionModal = true) } + return + } + // TODO: 북마크 토글 API 연동 + Timber.d("toggleBookmark: $contentId") + } + + // 저장 취소 제한 안내 모달 닫기 + fun dismissBookmarkRestrictionModal() { + _uiState.update { it.copy(showBookmarkRestrictionModal = false) } + } +} diff --git a/app/src/main/java/com/flint/presentation/profile/component/CollectionCreateContentBookmark.kt b/app/src/main/java/com/flint/presentation/profile/component/CollectionCreateContentBookmark.kt index d3affeca..b681a12d 100644 --- a/app/src/main/java/com/flint/presentation/profile/component/CollectionCreateContentBookmark.kt +++ b/app/src/main/java/com/flint/presentation/profile/component/CollectionCreateContentBookmark.kt @@ -141,7 +141,7 @@ private fun CollectionCreateContentBookmarkInfo( text = createdYear.toString(), modifier = Modifier.fillMaxWidth(), color = FlintTheme.colors.gray300, - style = FlintTheme.typography.body1R16, + style = FlintTheme.typography.caption1M12, ) Spacer(modifier = Modifier.weight(1f)) @@ -196,7 +196,7 @@ private fun CollectionCreateContentBookmarkMore( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "작품 보러가기", + text = "시청 가능한 OTT", color = FlintTheme.colors.white, style = FlintTheme.typography.body2R14, ) diff --git a/app/src/main/java/com/flint/presentation/profile/uistate/SavedContentUiState.kt b/app/src/main/java/com/flint/presentation/profile/uistate/SavedContentUiState.kt new file mode 100644 index 00000000..03c435a7 --- /dev/null +++ b/app/src/main/java/com/flint/presentation/profile/uistate/SavedContentUiState.kt @@ -0,0 +1,51 @@ +package com.flint.presentation.profile.uistate + +import androidx.compose.runtime.Immutable +import com.flint.core.common.util.UiState +import com.flint.domain.model.content.BookmarkedContentItemModel +import com.flint.domain.model.content.BookmarkedContentListModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +@Immutable +data class SavedContentUiState( + val searchKeyword: String = "", + val contents: UiState = UiState.Loading, + val showBookmarkRestrictionModal: Boolean = false, +) { + /** + * 검색어로 필터링된 콘텐츠 목록 + * 검색어가 비어있으면 전체 목록을 반환 + */ + val filteredContents: ImmutableList + get() { + val all = (contents as? UiState.Success)?.data?.contents ?: persistentListOf() + return if (searchKeyword.isBlank()) { + all + } else { + all.filter { it.title.contains(searchKeyword.trim(), ignoreCase = true) } + .toPersistentList() + } + } + + /** + * 화면 상단 "총 n개"에 사용할 카운트 (전체 저장 작품 수). + */ + val totalCount: Int + get() = (contents as? UiState.Success)?.data?.contents?.size ?: 0 + + companion object { + /** + * 취향 키워드 분석을 위해 최소 유지해야 하는 저장 작품 수. + * 저장 작품이 이 값과 같을 때 북마크 취소를 시도하면 안내 모달을 노출한다. + */ + const val MIN_REQUIRED_COUNT = 5 + + val Empty = SavedContentUiState() + + val Fake = SavedContentUiState( + contents = UiState.Success(BookmarkedContentListModel.FakeList), + ) + } +}