diff --git a/app/src/main/java/com/flint/data/api/SearchApi.kt b/app/src/main/java/com/flint/data/api/SearchApi.kt index a619a7ca..1973e130 100644 --- a/app/src/main/java/com/flint/data/api/SearchApi.kt +++ b/app/src/main/java/com/flint/data/api/SearchApi.kt @@ -14,8 +14,11 @@ interface SearchApi { @Query("size") size: Int ) : BaseResponse - @GET("/api/v1/search/contents") + @GET("/api/v1/contents/search") suspend fun getSearchContentList( - @Query("keyword") keyword: String? + @Query("keyword") keyword: String? = null, + @Query("genre") genre: String? = null, + @Query("cursor") cursor: Int = 1, + @Query("size") size: Int = 20, ): BaseResponse } diff --git a/app/src/main/java/com/flint/data/api/TermsApi.kt b/app/src/main/java/com/flint/data/api/TermsApi.kt new file mode 100644 index 00000000..5c34d620 --- /dev/null +++ b/app/src/main/java/com/flint/data/api/TermsApi.kt @@ -0,0 +1,20 @@ +package com.flint.data.api + +import com.flint.data.dto.base.BaseResponse +import com.flint.data.dto.terms.response.TermResponseDto +import com.flint.data.dto.terms.response.TermsListResponseDto +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +interface TermsApi { + @GET("/api/v1/terms") + suspend fun getTermsList( + @Query("type") type: String? = null, + ): BaseResponse + + @GET("/api/v1/terms/{termsId}") + suspend fun getTermsDetail( + @Path("termsId") termsId: Long, + ): BaseResponse +} diff --git a/app/src/main/java/com/flint/data/di/ServiceModule.kt b/app/src/main/java/com/flint/data/di/ServiceModule.kt index 71d9109b..28960210 100644 --- a/app/src/main/java/com/flint/data/di/ServiceModule.kt +++ b/app/src/main/java/com/flint/data/di/ServiceModule.kt @@ -7,6 +7,7 @@ import com.flint.data.api.ContentApi import com.flint.data.api.HomeApi import com.flint.data.api.SearchApi import com.flint.data.api.StorageApi +import com.flint.data.api.TermsApi import com.flint.data.api.UserApi import dagger.Module import dagger.Provides @@ -49,4 +50,8 @@ object ServiceModule { @Provides @Singleton fun provideStorageApi(retrofit: Retrofit): StorageApi = retrofit.create(StorageApi::class.java) + + @Provides + @Singleton + fun provideTermsApi(retrofit: Retrofit): TermsApi = retrofit.create(TermsApi::class.java) } diff --git a/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt b/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt index c725efb8..796f1774 100644 --- a/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt +++ b/app/src/main/java/com/flint/data/di/interceptor/NetworkErrorInterceptor.kt @@ -39,18 +39,24 @@ class NetworkErrorInterceptor @Inject constructor( response } catch (e: UnknownHostException) { - scope.launch { - networkErrorManager.emitError(NetworkError.NoInternet) + if (!chain.call().isCanceled()) { + scope.launch { + networkErrorManager.emitError(NetworkError.NoInternet) + } } throw e } catch (e: SocketTimeoutException) { - scope.launch { - networkErrorManager.emitError(NetworkError.Timeout) + if (!chain.call().isCanceled()) { + scope.launch { + networkErrorManager.emitError(NetworkError.Timeout) + } } throw e } catch (e: IOException) { - scope.launch { - networkErrorManager.emitError(NetworkError.UnknownError(e.message)) + if (!chain.call().isCanceled()) { + scope.launch { + networkErrorManager.emitError(NetworkError.UnknownError(e.message)) + } } throw e } diff --git a/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt b/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt index 5922047d..baa840e6 100644 --- a/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt +++ b/app/src/main/java/com/flint/data/dto/auth/request/SignupRequestDto.kt @@ -10,7 +10,7 @@ data class SignupRequestDto( @SerialName("nickname") val nickname: String, @SerialName("favoriteContentIds") - val favoriteContentIds: List, - @SerialName("subscribedOttIds") - val subscribedOttIds: List, + val favoriteContentIds: List, + @SerialName("agreedTermsIds") + val agreedTermsIds: List, ) diff --git a/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt b/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt index f89f75a6..858d6eff 100644 --- a/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt +++ b/app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt @@ -5,8 +5,10 @@ import kotlinx.serialization.Serializable @Serializable data class SearchContentsResponseDto( - @SerialName("contents") - val contents: List + @SerialName("data") + val data: List, + @SerialName("meta") + val meta: Meta? = null, ) { @Serializable data class Content( @@ -19,6 +21,16 @@ data class SearchContentsResponseDto( @SerialName("posterUrl") val posterUrl: String, @SerialName("year") - val year: Int + val year: Int, ) -} \ No newline at end of file + + @Serializable + data class Meta( + @SerialName("type") + val type: String? = null, + @SerialName("returned") + val returned: Int? = null, + @SerialName("nextCursor") + val nextCursor: String? = null, + ) +} diff --git a/app/src/main/java/com/flint/data/dto/terms/response/TermResponseDto.kt b/app/src/main/java/com/flint/data/dto/terms/response/TermResponseDto.kt new file mode 100644 index 00000000..273e7d45 --- /dev/null +++ b/app/src/main/java/com/flint/data/dto/terms/response/TermResponseDto.kt @@ -0,0 +1,22 @@ +package com.flint.data.dto.terms.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TermResponseDto( + @SerialName("id") + val id: String, + @SerialName("type") + val type: String, + @SerialName("version") + val version: Int, + @SerialName("title") + val title: String, + @SerialName("content") + val content: String, + @SerialName("required") + val required: Boolean, + @SerialName("activeAt") + val activeAt: String, +) diff --git a/app/src/main/java/com/flint/data/dto/terms/response/TermsListResponseDto.kt b/app/src/main/java/com/flint/data/dto/terms/response/TermsListResponseDto.kt new file mode 100644 index 00000000..5de0c62c --- /dev/null +++ b/app/src/main/java/com/flint/data/dto/terms/response/TermsListResponseDto.kt @@ -0,0 +1,10 @@ +package com.flint.data.dto.terms.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class TermsListResponseDto( + @SerialName("terms") + val terms: List, +) diff --git a/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt b/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt index 924f306f..c7c47841 100644 --- a/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt +++ b/app/src/main/java/com/flint/domain/mapper/auth/SignupMapper.kt @@ -10,7 +10,7 @@ fun SignupRequestModel.toDto(): SignupRequestDto = tempToken = tempToken, nickname = nickname, favoriteContentIds = favoriteContentIds, - subscribedOttIds = subscribedOttIds, + agreedTermsIds = agreedTermsIds, ) fun SignupResponseDto.toModel(): SignupResponseModel = 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 6ff5b26f..2a8b4f75 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,7 @@ import kotlinx.collections.immutable.toImmutableList fun SearchContentsResponseDto.toModel(): SearchContentListModel { return SearchContentListModel( - contents = this.contents.map { it.toModel() }.toImmutableList() + contents = this.data.map { it.toModel() }.toImmutableList() ) } diff --git a/app/src/main/java/com/flint/domain/mapper/terms/TermsMapper.kt b/app/src/main/java/com/flint/domain/mapper/terms/TermsMapper.kt new file mode 100644 index 00000000..707ff0e4 --- /dev/null +++ b/app/src/main/java/com/flint/domain/mapper/terms/TermsMapper.kt @@ -0,0 +1,17 @@ +package com.flint.domain.mapper.terms + +import com.flint.data.dto.terms.response.TermResponseDto +import com.flint.domain.model.terms.TermModel + +fun TermResponseDto.toModel(): TermModel = + TermModel( + id = id, + type = type, + version = version, + title = title, + content = content, + required = required, + activeAt = activeAt, + ) + +fun List.toModel(): List = map { it.toModel() } diff --git a/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt b/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt index 16cd26a6..31667447 100644 --- a/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt +++ b/app/src/main/java/com/flint/domain/model/auth/SignupModel.kt @@ -3,8 +3,8 @@ package com.flint.domain.model.auth data class SignupRequestModel( val tempToken: String, val nickname: String, - val favoriteContentIds: List, - val subscribedOttIds: List, + val favoriteContentIds: List, + val agreedTermsIds: List, ) data class SignupResponseModel( diff --git a/app/src/main/java/com/flint/domain/model/terms/TermModel.kt b/app/src/main/java/com/flint/domain/model/terms/TermModel.kt new file mode 100644 index 00000000..97fe25b2 --- /dev/null +++ b/app/src/main/java/com/flint/domain/model/terms/TermModel.kt @@ -0,0 +1,11 @@ +package com.flint.domain.model.terms + +data class TermModel( + val id: String, + val type: String, + val version: Int, + val title: String, + val content: String, + val required: Boolean, + val activeAt: String, +) 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 a81cbf82..56c81e07 100644 --- a/app/src/main/java/com/flint/domain/repository/SearchRepository.kt +++ b/app/src/main/java/com/flint/domain/repository/SearchRepository.kt @@ -14,6 +14,18 @@ class SearchRepository @Inject constructor( suspend fun getBookmarkedContentList(keyword: String, cursor: Int, size: Int): Result> = suspendRunCatching { apiService.getBookmarkedContentList(keyword, cursor, size).data.toModel() } - suspend fun getSearchContentList(keyword: String?): Result = - suspendRunCatching { apiService.getSearchContentList(keyword).data.toModel() } + suspend fun getSearchContentList( + keyword: String? = null, + genre: String? = null, + cursor: Int = 1, + size: Int = 20, + ): Result = + suspendRunCatching { + apiService.getSearchContentList( + keyword = keyword, + genre = genre, + cursor = cursor, + size = size, + ).data.toModel() + } } diff --git a/app/src/main/java/com/flint/domain/repository/TermsRepository.kt b/app/src/main/java/com/flint/domain/repository/TermsRepository.kt new file mode 100644 index 00000000..b994adb2 --- /dev/null +++ b/app/src/main/java/com/flint/domain/repository/TermsRepository.kt @@ -0,0 +1,16 @@ +package com.flint.domain.repository + +import com.flint.core.common.util.suspendRunCatching +import com.flint.data.api.TermsApi +import com.flint.domain.mapper.terms.toModel +import com.flint.domain.model.terms.TermModel +import javax.inject.Inject + +class TermsRepository @Inject constructor( + private val api: TermsApi, +) { + suspend fun getTermsList(type: String? = null): Result> = + suspendRunCatching { + api.getTermsList(type).data.terms.toModel() + } +} 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 1dd7fe47..e0108685 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingContentScreen.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingContentScreen.kt @@ -171,8 +171,8 @@ fun OnboardingContentScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - items(OnboardingContentUiState.GENRES) { genre -> - val isSelected = genre in contentUiState.selectedGenres + items(OnboardingContentUiState.GENRES.keys.toList()) { genre -> + val isSelected = contentUiState.selectedGenre == genre FlintGenreChip( text = genre, isSelected = isSelected, @@ -375,16 +375,14 @@ private fun OnboardingContentScreenEmptyPreview() { @Preview(showBackground = true, name = "장르 칩 인터랙티브") @Composable private fun OnboardingContentScreenGenreInteractivePreview() { - var selectedGenres by remember { mutableStateOf(setOf()) } + var selectedGenre by remember { mutableStateOf(null) } FlintTheme { OnboardingContentScreen( nickname = "안비", contentUiState = OnboardingContentUiState( searchResults = UiState.Success(SearchContentListModel.FakeList), - selectedGenres = selectedGenres.toList().let { - kotlinx.collections.immutable.persistentListOf(*it.toTypedArray()) - }, + selectedGenre = selectedGenre, ), onBackClick = {}, onNextClick = {}, @@ -394,11 +392,7 @@ private fun OnboardingContentScreenGenreInteractivePreview() { onContentClick = {}, onRemoveContent = {}, onGenreClick = { genre -> - selectedGenres = if (genre in selectedGenres) { - selectedGenres - genre - } else { - selectedGenres + genre - } + selectedGenre = if (selectedGenre == genre) null else genre }, ) } diff --git a/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt b/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt index 41a7dcb7..b08b675b 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingTermsScreen.kt @@ -2,6 +2,7 @@ package com.flint.presentation.onboarding import androidx.compose.animation.AnimatedVisibility 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.Row @@ -12,10 +13,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,63 +28,61 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flint.R import com.flint.core.common.extension.noRippleClickable +import com.flint.core.common.util.UiState import com.flint.core.designsystem.component.button.FlintButtonState import com.flint.core.designsystem.component.button.FlintLargeButton import com.flint.core.designsystem.component.topappbar.FlintBackTopAppbar import com.flint.core.designsystem.theme.FlintTheme - -private data class TermItem( - val title: String, - val description: String, - val detailUrl: String, -) - -private val terms = listOf( - TermItem( - title = "(필수) 서비스 이용 약관 동의", - description = "본 약관은 서비스 이용과 관련한 기본적인 권리·의무 및 책임사항을 규정합니다.", - detailUrl = "", - ), - TermItem( - title = "(필수) 개인정보 처리 방침 동의", - description = "서비스 제공을 위해 개인정보를 수집·이용합니다.\n" + - "콘텐츠 추천, 컬렉션 생성 및 공유, 맞춤형 탐색 경험 제공을 위한 이용 기록 및 취향 정보 처리 내용이 포함됩니다.\n\n" + - "수집 항목: 계정 정보, 취향 정보, 컬렉션 및 콘텐츠 활동, 서비스 이용 기록 등\n\n" + - "수집 목적: 개인화 추천 제공, 컬렉션 생성 및 공유, 서비스 운영 및 이용자 보호", - detailUrl = "", - ), -) +import com.flint.domain.model.terms.TermModel @Composable fun OnboardingTermsRoute( paddingValues: PaddingValues, navigateUp: () -> Unit, navigateToOnboardingContent: () -> Unit, + viewModel: OnboardingViewModel, ) { + val termsUiState by viewModel.termsUiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadTerms() + } + OnboardingTermsScreen( + termsUiState = termsUiState, onBackClick = navigateUp, - onAgreeClick = navigateToOnboardingContent, + onAgreeClick = { agreedIds -> + viewModel.agreeToTerms(agreedIds) + navigateToOnboardingContent() + }, modifier = Modifier.padding(paddingValues), ) } @Composable fun OnboardingTermsScreen( + termsUiState: OnboardingTermsUiState, onBackClick: () -> Unit, - onAgreeClick: () -> Unit, + onAgreeClick: (List) -> Unit, modifier: Modifier = Modifier, ) { - var checkedStates by remember { mutableStateOf(List(terms.size) { false }) } - var expandedStates by remember { mutableStateOf(List(terms.size) { false }) } - val allChecked = checkedStates.all { it } - val uriHandler = LocalUriHandler.current + val terms = (termsUiState.termsState as? UiState.Success)?.data ?: emptyList() + var checkedStates by remember(terms) { mutableStateOf(List(terms.size) { false }) } + var expandedStates by remember(terms) { mutableStateOf(List(terms.size) { false }) } + + // 필수 약관이 모두 체크되어야 버튼 활성화 + val requiredAllChecked = terms.isNotEmpty() && terms.indices + .filter { terms[it].required } + .all { checkedStates[it] } + + val allChecked = terms.isNotEmpty() && checkedStates.all { it } Column( modifier = modifier @@ -132,34 +133,71 @@ fun OnboardingTermsScreen( HorizontalDivider(thickness = 4.dp, color = FlintTheme.colors.gray600) - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - Spacer(Modifier.height(16.dp)) + when (termsUiState.termsState) { + is UiState.Loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = FlintTheme.colors.primary200) + } + } - terms.forEachIndexed { index, term -> - TermRow( - term = term, - isChecked = checkedStates[index], - isExpanded = expandedStates[index], - onCheckClick = { - checkedStates = checkedStates.toMutableList().also { it[index] = !it[index] } - }, - onExpandClick = { - expandedStates = expandedStates.toMutableList().also { it[index] = !it[index] } - }, - onDetailClick = { - if (term.detailUrl.isNotEmpty()) uriHandler.openUri(term.detailUrl) - }, - ) - Spacer(Modifier.height(4.dp)) + is UiState.Failure -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = "약관을 불러오지 못했습니다.", + style = FlintTheme.typography.body1R16, + color = FlintTheme.colors.gray400, + ) + } + } + + else -> { + Column(modifier = Modifier.padding(horizontal = 16.dp)) { + Spacer(Modifier.height(16.dp)) + + terms.forEachIndexed { index, term -> + TermRow( + term = term, + isChecked = checkedStates[index], + isExpanded = expandedStates[index], + onCheckClick = { + checkedStates = checkedStates.toMutableList() + .also { it[index] = !it[index] } + }, + onExpandClick = { + expandedStates = expandedStates.toMutableList() + .also { it[index] = !it[index] } + }, + onDetailClick = { + // TODO: 노션 약관 링크 확정 후 내부 웹뷰로 열기 + }, + ) + Spacer(Modifier.height(4.dp)) + } + } } } } FlintLargeButton( text = "동의하기", - state = if (allChecked) FlintButtonState.Able else FlintButtonState.Disable, - onClick = onAgreeClick, - enabled = allChecked, + state = if (requiredAllChecked) FlintButtonState.Able else FlintButtonState.Disable, + onClick = { + val agreedIds = terms.indices + .filter { checkedStates[it] } + .map { terms[it].id } + onAgreeClick(agreedIds) + }, + enabled = requiredAllChecked, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 20.dp), @@ -169,13 +207,15 @@ fun OnboardingTermsScreen( @Composable private fun TermRow( - term: TermItem, + term: TermModel, isChecked: Boolean, isExpanded: Boolean, onCheckClick: () -> Unit, onExpandClick: () -> Unit, onDetailClick: () -> Unit, ) { + val requiredPrefix = if (term.required) "(필수) " else "(선택) " + Column(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier @@ -193,7 +233,7 @@ private fun TermRow( ) Spacer(Modifier.width(12.dp)) Text( - text = term.title, + text = requiredPrefix + term.title, style = FlintTheme.typography.body1R16, color = FlintTheme.colors.white, modifier = Modifier.weight(1f), @@ -219,7 +259,7 @@ private fun TermRow( .padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 16.dp), ) { Text( - text = term.description, + text = term.content, style = FlintTheme.typography.body2R14, color = FlintTheme.colors.gray300, ) @@ -243,6 +283,39 @@ private fun TermRow( private fun OnboardingTermsScreenPreview() { FlintTheme { OnboardingTermsScreen( + termsUiState = OnboardingTermsUiState( + termsState = UiState.Success( + listOf( + TermModel( + id = "1", + type = "SERVICE", + version = 1, + title = "서비스 이용약관", + content = "본 약관은 서비스 이용과 관련한 기본적인 권리·의무 및 책임사항을 규정합니다.", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ), + TermModel( + id = "2", + type = "PRIVACY", + version = 1, + title = "개인정보 처리 방침", + content = "서비스 제공을 위해 개인정보를 수집·이용합니다.", + required = true, + activeAt = "2026-05-13T10:48:54.554Z", + ), + TermModel( + id = "3", + type = "MARKETING", + version = 1, + title = "마케팅 정보 수신 동의", + content = "이벤트 및 마케팅 정보를 수신합니다.", + required = false, + activeAt = "2026-05-13T10:48:54.554Z", + ), + ) + ) + ), onBackClick = {}, onAgreeClick = {}, ) 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 6dceb40d..b19cbbb8 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingUiState.kt @@ -2,10 +2,16 @@ package com.flint.presentation.onboarding import com.flint.core.common.util.UiState import com.flint.domain.model.search.SearchContentItemModel +import com.flint.domain.model.terms.TermModel import com.flint.domain.type.OttType import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +data class OnboardingTermsUiState( + val termsState: UiState> = UiState.Empty, + val agreedTermsIds: List = emptyList(), +) + enum class NicknameErrorType { DUPLICATE, // 이미 사용 중인 닉네임 INVALID_FORMAT // 한글, 영문 외 문자 포함 @@ -48,13 +54,19 @@ data class OnboardingContentUiState( val searchResults: UiState> = UiState.Empty, val selectedContents: ImmutableList = persistentListOf(), val isSearching: Boolean = false, - val selectedGenres: ImmutableList = persistentListOf(), + val selectedGenre: String? = null, ) { companion object { const val REQUIRED_SELECTION_COUNT = 7 - val GENRES = listOf( - "액션", "로맨스", "SF", "드라마", "코미디", "호러" + // 표시용(Korean) → API enum 매핑 + val GENRES: Map = linkedMapOf( + "액션" to "ACTION", + "로맨스" to "ROMANCE", + "SF" to "SCIENCE_FICTION", + "드라마" to "DRAMA", + "코미디" to "COMEDY", + "호러" to "HORROR", ) } 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 ed766513..e0146f71 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/OnboardingViewModel.kt @@ -9,6 +9,7 @@ import com.flint.core.navigation.Route import com.flint.domain.model.auth.SignupRequestModel import com.flint.domain.repository.AuthRepository import com.flint.domain.repository.SearchRepository +import com.flint.domain.repository.TermsRepository import com.flint.domain.repository.UserRepository import com.flint.domain.type.OttType import dagger.hilt.android.lifecycle.HiltViewModel @@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -28,6 +30,7 @@ class OnboardingViewModel private val userRepository: UserRepository, private val searchRepository: SearchRepository, private val authRepository: AuthRepository, + private val termsRepository: TermsRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -42,9 +45,37 @@ class OnboardingViewModel private val _ottUiState = MutableStateFlow(OnboardingOttUiState()) val ottUiState: StateFlow = _ottUiState.asStateFlow() + private val _termsUiState = MutableStateFlow(OnboardingTermsUiState()) + val termsUiState: StateFlow = _termsUiState.asStateFlow() + private val _signupUiState = MutableStateFlow(OnboardingSignupUiState()) val signupUiState: StateFlow = _signupUiState.asStateFlow() + private var searchJob: Job? = null + + // ---------- onboarding terms ---------- + fun loadTerms() { + if (_termsUiState.value.termsState is UiState.Loading || + _termsUiState.value.termsState is UiState.Success + ) return + + viewModelScope.launch { + _termsUiState.update { it.copy(termsState = UiState.Loading) } + termsRepository.getTermsList() + .onSuccess { terms -> + _termsUiState.update { it.copy(termsState = UiState.Success(terms)) } + } + .onFailure { error -> + _termsUiState.update { it.copy(termsState = UiState.Failure) } + Timber.e(error, "Failed to load terms") + } + } + } + + fun agreeToTerms(termIds: List) { + _termsUiState.update { it.copy(agreedTermsIds = termIds) } + } + // ---------- onboarding profile ---------- fun updateNickname(nickname: String) { if (nickname.length <= OnboardingProfileUiState.MAX_LENGTH) { @@ -103,31 +134,37 @@ class OnboardingViewModel } fun loadInitialContents() { - getSearchContentList(null) + getSearchContentList(keyword = null, genre = null) } fun searchContents() { - val keyword = _contentUiState.value.searchKeyword - getSearchContentList(keyword.ifEmpty { null }) + val keyword = _contentUiState.value.searchKeyword.ifEmpty { null } + val genre = _contentUiState.value.selectedGenre + getSearchContentList(keyword = keyword, genre = genre) } fun selectGenre(genre: String) { _contentUiState.update { currentState -> - val current = currentState.selectedGenres - val newSelectedGenres = if (genre in current) { - current.filterNot { it == genre }.toImmutableList() - } else { - (current + genre).toImmutableList() - } - currentState.copy(selectedGenres = newSelectedGenres) + val newSelected = if (currentState.selectedGenre == genre) null else genre + currentState.copy(selectedGenre = newSelected) } + // 장르 선택/해제 시 즉시 재검색 + val keyword = _contentUiState.value.searchKeyword.ifEmpty { null } + val selected = _contentUiState.value.selectedGenre + getSearchContentList(keyword = keyword, genre = selected) } - private fun getSearchContentList(keyword: String?) { - viewModelScope.launch { + private fun getSearchContentList(keyword: String?, genre: String?) { + searchJob?.cancel() + searchJob = viewModelScope.launch { _contentUiState.update { it.copy(searchResults = UiState.Loading) } - searchRepository.getSearchContentList(keyword) + val genreApiValue = genre?.let { OnboardingContentUiState.GENRES[it] } + + searchRepository.getSearchContentList( + keyword = keyword, + genre = genreApiValue, + ) .onSuccess { result -> _contentUiState.update { currentState -> currentState.copy( @@ -184,8 +221,8 @@ class OnboardingViewModel val signupRequest = SignupRequestModel( tempToken = tempToken, nickname = _uiState.value.nickname, - favoriteContentIds = _contentUiState.value.selectedContents.map { it.id.toLong() }, - subscribedOttIds = _ottUiState.value.selectedOtts.map { it.id } + favoriteContentIds = _contentUiState.value.selectedContents.map { it.id }, + agreedTermsIds = _termsUiState.value.agreedTermsIds, ) authRepository.signup(signupRequest) diff --git a/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt b/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt index bcd741a6..c5533b7f 100644 --- a/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt +++ b/app/src/main/java/com/flint/presentation/onboarding/navigation/OnboardingNavigation.kt @@ -55,6 +55,7 @@ fun NavGraphBuilder.onBoardingNavGraph( paddingValues = paddingValues, navigateUp = navController::navigateUp, navigateToOnboardingContent = navController::navigateToOnboardingContent, + viewModel = sharedViewModel, ) }