Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/main/java/com/flint/data/api/AuthApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -22,5 +22,7 @@ interface AuthApi {
): BaseResponse<SocialVerifyResponseDto>

@POST("/api/v1/auth/withdraw")
suspend fun withdraw(): WithdrawResponseDto
suspend fun withdraw(
@Body requestDto: WithdrawRequestDto,
): WithdrawResponseDto
}
Original file line number Diff line number Diff line change
@@ -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<String>,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ data class SearchContentItemModel(
)

data class SearchContentListModel(
val contents: ImmutableList<SearchContentItemModel>
val contents: ImmutableList<SearchContentItemModel>,
val nextCursor: String? = null,
) {
companion object {
val FakeList =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -38,9 +39,10 @@ class AuthRepository @Inject constructor(
result
}

suspend fun withdraw(): Result<Unit> =
// :TODO 일단 10으로 고정해둠 수정예정
suspend fun withdraw(agreedTermsIds: List<String> = listOf("10")): Result<Unit> =
suspendRunCatching {
api.withdraw()
api.withdraw(WithdrawRequestDto(agreedTermsIds = agreedTermsIds))
Comment on lines +42 to +45
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Jun 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

탈퇴 약관 ID 기본값을 저장소 API에 고정하지 마세요.

Line 43의 listOf("10") 때문에 호출부가 값을 빠뜨리면 항상 특정 약관 ID로 탈퇴 요청이 전송됩니다. 약관 ID가 서버 설정과 달라지는 순간 탈퇴가 실패하거나 잘못된 동의 이력이 남을 수 있어서, 여기서는 기본값을 두지 말고 현재 화면에서 선택된 값을 반드시 넘기도록 강제하는 편이 안전합니다.

변경 예시
-    // :TODO 일단 10으로 고정해둠 수정예정
-    suspend fun withdraw(agreedTermsIds: List<String> = listOf("10")): Result<Unit> =
+    suspend fun withdraw(agreedTermsIds: List<String>): Result<Unit> =
         suspendRunCatching {
             api.withdraw(WithdrawRequestDto(agreedTermsIds = agreedTermsIds))
             preferencesManager.clearAll()
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/flint/domain/repository/AuthRepository.kt` around lines
42 - 45, The withdraw function currently uses a hardcoded default agreedTermsIds
= listOf("10") which can send incorrect consent data; remove the default so
suspend fun withdraw(agreedTermsIds: List<String>): Result<Unit> requires
callers to pass the selected IDs, and construct the WithdrawRequestDto with that
parameter as before (WithdrawRequestDto(agreedTermsIds = agreedTermsIds)) and
update all call sites that relied on the default to supply the actual selected
list; keep the suspendRunCatching and api.withdraw(…) usage intact.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위에 TODO로 남겨둠 수정예정입니다

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ckals413, TODO 주석으로 남겨두신 것 확인했습니다. 나중에 수정하실 때 잊지 않도록 GitHub 이슈로 트래킹해 드릴까요? 원하시면 말씀해 주세요.


🧠 Learnings used
Learnt from: nahy-512
Repo: imflint/Flint-Android PR: 135
File: app/src/main/java/com/flint/domain/repository/AuthRepository.kt:1-22
Timestamp: 2026-01-19T00:48:44.889Z
Learning: Architect the Android app following Google's recommended Android App Architecture for this repo: place the data layer innermost, the domain layer can depend on the data layer, and avoid circular dependencies with other layers. For Kotlin files under app/src/main/java, ensure accompanying architecture aligns with this guidance (e.g., domain interfaces depending on data layer implementations or abstractions, not the other way around).

Learnt from: giovannijunseokim
Repo: imflint/Flint-Android PR: 153
File: app/src/main/java/com/flint/presentation/collectiondetail/CollectionDetailViewModel.kt:98-105
Timestamp: 2026-01-21T08:37:42.767Z
Learning: Guideline: For APIs like toggleContentBookmark that do not return a bookmarkCount, perform optimistic updates by updating the isBookmarked state on the client and compute bookmarkCount locally if needed. Do not rely on the server for the count; ensure the server response only conveys the boolean bookmarked state and synchronize this state accordingly. This applies to Kotlin Android ViewModels and related UI state management across files that handle similar bookmark toggle endpoints.

preferencesManager.clearAll()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ class SearchRepository @Inject constructor(

suspend fun getSearchContentList(
keyword: String? = null,
genre: List<String>? = null,
genres: List<String>? = null,
mediaType: String? = null,
cursor: String? = null,
size: Int = 20,
): Result<SearchContentListModel> =
suspendRunCatching {
apiService.getSearchContentList(
keyword = keyword,
genre = genre,
genre = genres?.ifEmpty { null },
mediaType = mediaType,
cursor = cursor,
size = size,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
}
Expand All @@ -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 =
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -375,14 +387,14 @@ private fun OnboardingContentScreenEmptyPreview() {
@Preview(showBackground = true, name = "장르 칩 인터랙티브")
@Composable
private fun OnboardingContentScreenGenreInteractivePreview() {
var selectedGenre by remember { mutableStateOf<String?>(null) }
var selectedGenres by remember { mutableStateOf(emptySet<String>()) }

FlintTheme {
OnboardingContentScreen(
nickname = "안비",
contentUiState = OnboardingContentUiState(
searchResults = UiState.Success(SearchContentListModel.FakeList),
selectedGenre = selectedGenre,
selectedGenres = selectedGenres,
),
onBackClick = {},
onNextClick = {},
Expand All @@ -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
}
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ data class OnboardingContentUiState(
val searchResults: UiState<ImmutableList<SearchContentItemModel>> = UiState.Empty,
val selectedContents: ImmutableList<SearchContentItemModel> = persistentListOf(),
val isSearching: Boolean = false,
val selectedGenre: String? = null,
val selectedGenres: Set<String> = emptySet(),
val nextCursor: String? = null,
val isLoadingMore: Boolean = false,
) {
companion object {
const val REQUIRED_SELECTION_COUNT = 7
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
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")
Expand All @@ -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 }
Expand Down
Loading