diff --git a/app/src/main/java/com/flint/data/api/AuthApi.kt b/app/src/main/java/com/flint/data/api/AuthApi.kt index acf48dd9..26528159 100644 --- a/app/src/main/java/com/flint/data/api/AuthApi.kt +++ b/app/src/main/java/com/flint/data/api/AuthApi.kt @@ -4,10 +4,10 @@ import com.flint.data.dto.auth.request.SignupRequestDto import com.flint.data.dto.auth.request.SocialVerifyRequestDto import com.flint.data.dto.auth.response.SignupResponseDto import com.flint.data.dto.auth.response.SocialVerifyResponseDto +import com.flint.data.dto.auth.request.WithdrawRequestDto import com.flint.data.dto.auth.response.WithdrawResponseDto import com.flint.data.dto.base.BaseResponse import retrofit2.http.Body -import retrofit2.http.DELETE import retrofit2.http.POST interface AuthApi { @@ -22,5 +22,7 @@ interface AuthApi { ): BaseResponse @POST("/api/v1/auth/withdraw") - suspend fun withdraw(): WithdrawResponseDto + suspend fun withdraw( + @Body requestDto: WithdrawRequestDto, + ): WithdrawResponseDto } diff --git a/app/src/main/java/com/flint/data/dto/auth/request/WithdrawRequestDto.kt b/app/src/main/java/com/flint/data/dto/auth/request/WithdrawRequestDto.kt new file mode 100644 index 00000000..06a2aad9 --- /dev/null +++ b/app/src/main/java/com/flint/data/dto/auth/request/WithdrawRequestDto.kt @@ -0,0 +1,10 @@ +package com.flint.data.dto.auth.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WithdrawRequestDto( + @SerialName("agreedTermsIds") + val agreedTermsIds: List, +) diff --git a/app/src/main/java/com/flint/domain/mapper/search/SearchContentMapper.kt b/app/src/main/java/com/flint/domain/mapper/search/SearchContentMapper.kt index 2a8b4f75..84ce6a85 100644 --- a/app/src/main/java/com/flint/domain/mapper/search/SearchContentMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/search/SearchContentMapper.kt @@ -7,7 +7,8 @@ import kotlinx.collections.immutable.toImmutableList fun SearchContentsResponseDto.toModel(): SearchContentListModel { return SearchContentListModel( - contents = this.data.map { it.toModel() }.toImmutableList() + contents = this.data.map { it.toModel() }.toImmutableList(), + nextCursor = this.meta?.nextCursor, ) } diff --git a/app/src/main/java/com/flint/domain/model/search/SearchContentItemModel.kt b/app/src/main/java/com/flint/domain/model/search/SearchContentItemModel.kt index ba96abce..b3c4b8b8 100644 --- a/app/src/main/java/com/flint/domain/model/search/SearchContentItemModel.kt +++ b/app/src/main/java/com/flint/domain/model/search/SearchContentItemModel.kt @@ -12,7 +12,8 @@ data class SearchContentItemModel( ) data class SearchContentListModel( - val contents: ImmutableList + val contents: ImmutableList, + val nextCursor: String? = null, ) { companion object { val FakeList = diff --git a/app/src/main/java/com/flint/domain/repository/AuthRepository.kt b/app/src/main/java/com/flint/domain/repository/AuthRepository.kt index 773b0b86..c8ae60fd 100644 --- a/app/src/main/java/com/flint/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/AuthRepository.kt @@ -7,6 +7,7 @@ import com.flint.core.common.util.DataStoreKey.USER_NAME import com.flint.data.local.PreferencesManager import com.flint.core.common.util.suspendRunCatching import com.flint.data.api.AuthApi +import com.flint.data.dto.auth.request.WithdrawRequestDto import com.flint.domain.mapper.auth.toDto import com.flint.domain.mapper.auth.toModel import com.flint.domain.model.auth.SignupRequestModel @@ -38,9 +39,10 @@ class AuthRepository @Inject constructor( result } - suspend fun withdraw(): Result = + // :TODO 일단 10으로 고정해둠 수정예정 + suspend fun withdraw(agreedTermsIds: List = listOf("10")): Result = suspendRunCatching { - api.withdraw() + api.withdraw(WithdrawRequestDto(agreedTermsIds = agreedTermsIds)) preferencesManager.clearAll() } } \ No newline at end of file diff --git a/app/src/main/java/com/flint/domain/repository/SearchRepository.kt b/app/src/main/java/com/flint/domain/repository/SearchRepository.kt index 42d36b25..08c6d5ad 100644 --- a/app/src/main/java/com/flint/domain/repository/SearchRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/SearchRepository.kt @@ -16,7 +16,7 @@ class SearchRepository @Inject constructor( suspend fun getSearchContentList( keyword: String? = null, - genre: List? = null, + genres: List? = null, mediaType: String? = null, cursor: String? = null, size: Int = 20, @@ -24,7 +24,7 @@ class SearchRepository @Inject constructor( suspendRunCatching { apiService.getSearchContentList( keyword = keyword, - genre = genre, + genre = genres?.ifEmpty { null }, mediaType = mediaType, cursor = cursor, size = size, diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingContentScreen.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingContentScreen.kt index e0108685..3b2f97d9 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingContentScreen.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingContentScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions @@ -77,10 +78,11 @@ fun OnboardingContentRoute( onNextClick = navigateToOnboardingDone, onSearchKeywordChanged = viewModel::updateSearchKeyword, onSearchAction = viewModel::searchContents, - onClearAction = viewModel::loadInitialContents, + onClearAction = viewModel::clearSearchKeyword, onContentClick = viewModel::toggleContentSelection, onRemoveContent = viewModel::toggleContentSelection, onGenreClick = viewModel::selectGenre, + onLoadMore = viewModel::loadMoreContents, modifier = Modifier.padding(paddingValues), ) } @@ -97,9 +99,18 @@ fun OnboardingContentScreen( onContentClick: (SearchContentItemModel) -> Unit, onRemoveContent: (SearchContentItemModel) -> Unit, onGenreClick: (String) -> Unit, + onLoadMore: () -> Unit = {}, modifier: Modifier = Modifier, ) { val keyboardController = LocalSoftwareKeyboardController.current + val gridState = rememberLazyGridState() + + // 스크롤 끝 감지 → 다음 페이지 로드 + LaunchedEffect(gridState.canScrollForward) { + if (!gridState.canScrollForward && contentUiState.nextCursor != null && !contentUiState.isLoadingMore) { + onLoadMore() + } + } Column( modifier = @@ -122,6 +133,7 @@ fun OnboardingContentScreen( // 전체 콘텐츠를 LazyVerticalGrid로 구성 LazyVerticalGrid( columns = GridCells.Fixed(3), + state = gridState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), @@ -172,7 +184,7 @@ fun OnboardingContentScreen( verticalAlignment = Alignment.CenterVertically, ) { items(OnboardingContentUiState.GENRES.keys.toList()) { genre -> - val isSelected = contentUiState.selectedGenre == genre + val isSelected = contentUiState.selectedGenres.contains(genre) FlintGenreChip( text = genre, isSelected = isSelected, @@ -375,14 +387,14 @@ private fun OnboardingContentScreenEmptyPreview() { @Preview(showBackground = true, name = "장르 칩 인터랙티브") @Composable private fun OnboardingContentScreenGenreInteractivePreview() { - var selectedGenre by remember { mutableStateOf(null) } + var selectedGenres by remember { mutableStateOf(emptySet()) } FlintTheme { OnboardingContentScreen( nickname = "안비", contentUiState = OnboardingContentUiState( searchResults = UiState.Success(SearchContentListModel.FakeList), - selectedGenre = selectedGenre, + selectedGenres = selectedGenres, ), onBackClick = {}, onNextClick = {}, @@ -392,7 +404,11 @@ private fun OnboardingContentScreenGenreInteractivePreview() { onContentClick = {}, onRemoveContent = {}, onGenreClick = { genre -> - selectedGenre = if (selectedGenre == genre) null else genre + selectedGenres = if (selectedGenres.contains(genre)) { + selectedGenres - genre + } else { + selectedGenres + genre + } }, ) } diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt index e66dddb7..fd0d5a0c 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt @@ -63,7 +63,9 @@ data class OnboardingContentUiState( val searchResults: UiState> = UiState.Empty, val selectedContents: ImmutableList = persistentListOf(), val isSearching: Boolean = false, - val selectedGenre: String? = null, + val selectedGenres: Set = emptySet(), + val nextCursor: String? = null, + val isLoadingMore: Boolean = false, ) { companion object { const val REQUIRED_SELECTION_COUNT = 7 diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt index 5f1206f1..f63de214 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt @@ -144,41 +144,54 @@ class OnboardingViewModel } fun loadInitialContents() { - getSearchContentList(keyword = null, genre = null) + getSearchContentList(keyword = null, genres = emptySet()) + } + + fun clearSearchKeyword() { + _contentUiState.update { it.copy(searchKeyword = "") } + val genres = _contentUiState.value.selectedGenres + getSearchContentList(keyword = null, genres = genres) } fun searchContents() { val keyword = _contentUiState.value.searchKeyword.ifEmpty { null } - val genre = _contentUiState.value.selectedGenre - getSearchContentList(keyword = keyword, genre = genre) + val genres = _contentUiState.value.selectedGenres + getSearchContentList(keyword = keyword, genres = genres) } fun selectGenre(genre: String) { _contentUiState.update { currentState -> - val newSelected = if (currentState.selectedGenre == genre) null else genre - currentState.copy(selectedGenre = newSelected) + val newSelected = if (currentState.selectedGenres.contains(genre)) { + currentState.selectedGenres - genre + } else { + currentState.selectedGenres + genre + } + currentState.copy(selectedGenres = newSelected) } // 장르 선택/해제 시 즉시 재검색 val keyword = _contentUiState.value.searchKeyword.ifEmpty { null } - val selected = _contentUiState.value.selectedGenre - getSearchContentList(keyword = keyword, genre = selected) + val selected = _contentUiState.value.selectedGenres + getSearchContentList(keyword = keyword, genres = selected) } - private fun getSearchContentList(keyword: String?, genre: String?) { + private fun getSearchContentList(keyword: String?, genres: Set) { searchJob?.cancel() searchJob = viewModelScope.launch { - _contentUiState.update { it.copy(searchResults = UiState.Loading) } + _contentUiState.update { it.copy(searchResults = UiState.Loading, nextCursor = null) } - val genreApiValue = genre?.let { key -> OnboardingContentUiState.GENRES[key]?.let { listOf(it) } } + val genreApiValues = genres.mapNotNull { OnboardingContentUiState.GENRES[it] } + .ifEmpty { null } searchRepository.getSearchContentList( keyword = keyword, - genre = genreApiValue, + genres = genreApiValues, + cursor = null, ) .onSuccess { result -> _contentUiState.update { currentState -> currentState.copy( - searchResults = UiState.Success(result.contents) + searchResults = UiState.Success(result.contents), + nextCursor = result.nextCursor, ) } Timber.d("Search result: $result") @@ -190,6 +203,40 @@ class OnboardingViewModel } } + fun loadMoreContents() { + val state = _contentUiState.value + val cursor = state.nextCursor ?: return + if (state.isLoadingMore) return + val currentItems = (state.searchResults as? UiState.Success)?.data ?: return + + viewModelScope.launch { + _contentUiState.update { it.copy(isLoadingMore = true) } + + val keyword = state.searchKeyword.ifEmpty { null } + val genreApiValues = state.selectedGenres + .mapNotNull { OnboardingContentUiState.GENRES[it] } + .ifEmpty { null } + + searchRepository.getSearchContentList( + keyword = keyword, + genres = genreApiValues, + cursor = cursor, + ) + .onSuccess { result -> + val merged = (currentItems + result.contents).toImmutableList() + _contentUiState.update { it.copy( + searchResults = UiState.Success(merged), + nextCursor = result.nextCursor, + isLoadingMore = false, + )} + } + .onFailure { error -> + _contentUiState.update { it.copy(isLoadingMore = false) } + Timber.e(error) + } + } + } + fun toggleContentSelection(content: SearchContentItemModel) { _contentUiState.update { currentState -> val isAlreadySelected = currentState.selectedContents.any { it.id == content.id }