Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ fun QuestionScreen(
uiState.searchQuery,
uiState.selectedCategories,
uiState.showSolvedOnly,
uiState.showFavoritesOnly,
uiState.showLikedOnly,
) {
if (listState.firstVisibleItemIndex > 0) {
listState.scrollToItem(0)
Expand Down Expand Up @@ -79,9 +79,9 @@ fun QuestionScreen(
onCategoryToggle = viewModel::onCategoryToggle,
onToggleCategoryFilters = viewModel::onToggleCategoryFilters,
onSolvedFilterToggle = viewModel::onSolvedFilterToggle,
onFavoritesFilterToggle = viewModel::onFavoritesFilterToggle,
onLikedFilterToggle = viewModel::onLikedFilterToggle,
onQuestionClick = {},
onFavoriteToggle = viewModel::onFavoriteToggle,
onLikeToggle = viewModel::onLikeToggle,
)
}

Expand All @@ -93,9 +93,9 @@ private fun QuestionScreenContent(
onCategoryToggle: (Category) -> Unit,
onToggleCategoryFilters: () -> Unit,
onSolvedFilterToggle: () -> Unit,
onFavoritesFilterToggle: () -> Unit,
onLikedFilterToggle: () -> Unit,
onQuestionClick: (Long) -> Unit,
onFavoriteToggle: (Long) -> Unit,
onLikeToggle: (Long) -> Unit,
) {
Column(
modifier =
Expand All @@ -115,10 +115,10 @@ private fun QuestionScreenContent(
QuestionFilterChips(
selectedCategories = uiState.selectedCategories,
showSolvedOnly = uiState.showSolvedOnly,
showFavoritesOnly = uiState.showFavoritesOnly,
showLikedOnly = uiState.showLikedOnly,
onToggleCategoryFilters = onToggleCategoryFilters,
onSolvedFilterToggle = onSolvedFilterToggle,
onFavoritesFilterToggle = onFavoritesFilterToggle,
onLikedFilterToggle = onLikedFilterToggle,
)

Spacer(modifier = Modifier.height(Dimen.spacingMd))
Expand Down Expand Up @@ -156,7 +156,7 @@ private fun QuestionScreenContent(
QuestionList(
questions = uiState.filteredQuestions,
onQuestionClick = onQuestionClick,
onFavoriteToggle = onFavoriteToggle,
onLikeToggle = onLikeToggle,
listState = listState,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import org.jetbrains.compose.resources.stringResource
fun QuestionFilterChips(
selectedCategories: ImmutableSet<Category>,
showSolvedOnly: Boolean,
showFavoritesOnly: Boolean,
showLikedOnly: Boolean,
onToggleCategoryFilters: () -> Unit,
onSolvedFilterToggle: () -> Unit,
onFavoritesFilterToggle: () -> Unit,
onLikedFilterToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
Expand Down Expand Up @@ -63,8 +63,8 @@ fun QuestionFilterChips(

CategoryFilterChip(
text = stringResource(Res.string.question_filter_favorites),
selected = showFavoritesOnly,
onClick = onFavoritesFilterToggle,
selected = showLikedOnly,
onClick = onLikedFilterToggle,
leadingIcon = {
Icon(
imageVector = Icons.Filled.Star,
Expand All @@ -82,10 +82,10 @@ private fun QuestionFilterChipsPreview() {
QuestionFilterChips(
selectedCategories = persistentSetOf(),
showSolvedOnly = false,
showFavoritesOnly = false,
showLikedOnly = false,
onToggleCategoryFilters = {},
onSolvedFilterToggle = {},
onFavoritesFilterToggle = {},
onLikedFilterToggle = {},
)
}
}
Expand All @@ -97,10 +97,10 @@ private fun QuestionFilterChipsSelectedPreview() {
QuestionFilterChips(
selectedCategories = persistentSetOf(Category.Kotlin, Category.Android),
showSolvedOnly = true,
showFavoritesOnly = true,
showLikedOnly = true,
onToggleCategoryFilters = {},
onSolvedFilterToggle = {},
onFavoritesFilterToggle = {},
onLikedFilterToggle = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import kotlin.time.Instant
fun QuestionList(
questions: ImmutableList<Question>,
onQuestionClick: (Long) -> Unit,
onFavoriteToggle: (Long) -> Unit,
onLikeToggle: (Long) -> Unit,
modifier: Modifier = Modifier,
listState: LazyListState = rememberLazyListState(),
) {
Expand All @@ -40,9 +40,9 @@ fun QuestionList(
title = question.title,
category = question.category,
isSolved = question.isSolved,
isFavorite = question.isFavorite,
isLiked = question.isLiked,
onClick = { onQuestionClick(question.id) },
onFavoriteClick = { onFavoriteToggle(question.id) },
onLikeClick = { onLikeToggle(question.id) },
)
}
}
Expand All @@ -55,7 +55,7 @@ private fun QuestionListEmptyPreview() {
QuestionList(
questions = persistentListOf(),
onQuestionClick = {},
onFavoriteToggle = {},
onLikeToggle = {},
)
}
}
Expand All @@ -75,11 +75,11 @@ private fun QuestionListSinglePreview() {
createdAt = Instant.fromEpochMilliseconds(0),
updatedAt = Instant.fromEpochMilliseconds(0),
isSolved = false,
isFavorite = false,
isLiked = false,
),
),
onQuestionClick = {},
onFavoriteToggle = {},
onLikeToggle = {},
)
}
}
Expand All @@ -99,7 +99,7 @@ private fun QuestionListMultiplePreview() {
createdAt = Instant.fromEpochMilliseconds(0),
updatedAt = Instant.fromEpochMilliseconds(0),
isSolved = true,
isFavorite = true,
isLiked = true,
),
Question(
id = 2L,
Expand All @@ -109,7 +109,7 @@ private fun QuestionListMultiplePreview() {
createdAt = Instant.fromEpochMilliseconds(0),
updatedAt = Instant.fromEpochMilliseconds(0),
isSolved = false,
isFavorite = true,
isLiked = true,
),
Question(
id = 3L,
Expand All @@ -119,7 +119,7 @@ private fun QuestionListMultiplePreview() {
createdAt = Instant.fromEpochMilliseconds(0),
updatedAt = Instant.fromEpochMilliseconds(0),
isSolved = true,
isFavorite = false,
isLiked = false,
),
Question(
id = 4L,
Expand All @@ -129,7 +129,7 @@ private fun QuestionListMultiplePreview() {
createdAt = Instant.fromEpochMilliseconds(0),
updatedAt = Instant.fromEpochMilliseconds(0),
isSolved = false,
isFavorite = false,
isLiked = false,
),
Question(
id = 5L,
Expand All @@ -139,11 +139,11 @@ private fun QuestionListMultiplePreview() {
createdAt = Instant.fromEpochMilliseconds(0),
updatedAt = Instant.fromEpochMilliseconds(0),
isSolved = false,
isFavorite = false,
isLiked = false,
),
),
onQuestionClick = {},
onFavoriteToggle = {},
onLikeToggle = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ data class QuestionUiState(
val selectedCategories: ImmutableSet<Category>
get() = filter.categories.toSet().toImmutableSet()
val showSolvedOnly: Boolean get() = filter.solved
val showFavoritesOnly: Boolean get() = filter.liked
val showLikedOnly: Boolean get() = filter.liked

val filteredQuestions: ImmutableList<Question>
get() {
Expand All @@ -48,9 +48,21 @@ data class QuestionUiState(

fun clearSolvedFilter(): QuestionUiState = copy(filter = filter.clearSolvedFilter())

fun applyFavoritesFilter(): QuestionUiState = copy(filter = filter.applyFavoritesFilter())
fun applyLikedFilter(): QuestionUiState = copy(filter = filter.applyLikedFilter())

fun clearFavoritesFilter(): QuestionUiState = copy(filter = filter.clearFavoritesFilter())
fun clearLikedFilter(): QuestionUiState = copy(filter = filter.clearLikedFilter())

fun toggleQuestionLike(questionId: Long): QuestionUiState {
val updatedList =
allQuestions.toList().map { question ->
if (question.id == questionId) {
question.copy(isLiked = !question.isLiked)
} else {
question
}
}
return copy(allQuestions = Questions(updatedList))
}

fun loading(isLoading: Boolean): QuestionUiState = copy(isLoading = isLoading)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,12 @@ class QuestionViewModel(
applyFilter()
}

fun onFavoritesFilterToggle() {
fun onLikedFilterToggle() {
_uiState.update {
if (it.showFavoritesOnly) {
it.clearFavoritesFilter()
if (it.showLikedOnly) {
it.clearLikedFilter()
} else {
it.applyFavoritesFilter()
it.applyLikedFilter()
}
}
applyFilter()
Expand All @@ -82,8 +82,22 @@ class QuestionViewModel(
}
}

fun onFavoriteToggle(questionId: Long) {
// TODO: Implement favorite toggle
fun onLikeToggle(questionId: Long) {
viewModelScope.launch {
val currentQuestion = _uiState.value.filteredQuestions.find { it.id == questionId } ?: return@launch
val isCurrentlyLiked = currentQuestion.isLiked

_uiState.update { it.toggleQuestionLike(questionId) }

questionRepository
.toggleQuestionLike(questionId, isCurrentlyLiked)
.onFailure {
_uiState.update { state ->
state.toggleQuestionLike(questionId)
}
sendUiEvent(QuestionUiEvent.ShowError)
}
}
}
Comment on lines +85 to 101
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

fd QuestionViewModel.kt

Repository: chanho0908/DroidMorning

Length of output: 154


🏁 Script executed:

# Find the QuestionUiState class
rg "class QuestionUiState|data class QuestionUiState" -A 20

Repository: chanho0908/DroidMorning

Length of output: 2691


🏁 Script executed:

# Search for toggleQuestionLike implementation
rg "fun toggleQuestionLike" -B 2 -A 15

Repository: chanho0908/DroidMorning

Length of output: 4874


필터링된 목록에서만 검색하므로 특정 상황에서 좋아요 토글이 무시될 수 있습니다.

낙관적 업데이트 패턴과 실패 시 롤백 처리가 잘 구현되었습니다. toggleQuestionLike는 원본 데이터인 allQuestions를 업데이트하고, filteredQuestions는 계산 속성(computed property)으로서 자동으로 변경사항을 반영하므로 상태 동기화 문제는 없습니다.

다만 하나의 중요한 문제가 있습니다:

Line 87의 검색 범위 문제: filteredQuestions에서만 질문을 찾고 있어, 현재 활성화된 필터에 의해 숨겨진 질문은 좋아요 토글이 실패합니다. 예를 들어 사용자가 "좋아요한 문제만" 필터 상태에서 좋아요 중인 문제를 토글하려고 하면, 해당 질문이 filteredQuestions에 포함되지 않아 작업이 무시됩니다.

개선 방안: _uiState.value.allQuestions에서 검색하거나, allQuestions 접근을 위한 공개 속성을 추가하는 것을 고려해볼 수 있습니다.

성공 시 loadQuestions() 호출로 서버와 재동기화할 필요는 없어 보입니다. 현재 구현은 낙관적 업데이트의 장점(빠른 응답)을 유지하면서 필요시에만 롤백하므로 효율적입니다.

🤖 Prompt for AI Agents
In
`@composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionViewModel.kt`
around lines 85 - 101, The onLikeToggle function currently looks up the question
in _uiState.value.filteredQuestions which causes toggles to be ignored for items
hidden by filters; change the lookup to search the full list (e.g.,
_uiState.value.allQuestions.find { it.id == questionId } or expose an
allQuestions accessor) so the optimistic update and rollback (uses
toggleQuestionLike) always operate on the canonical item, leaving the rest of
the flow (calling _uiState.update { it.toggleQuestionLike(questionId) },
repository.toggleQuestionLike(...) and the onFailure rollback/sendUiEvent)
unchanged.


private fun loadQuestions() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,45 +1,50 @@
package com.peto.droidmorning.data.datasource.question.remote

import com.peto.droidmorning.data.model.LikeRequest
import com.peto.droidmorning.data.model.QuestionResponse
import io.github.jan.supabase.auth.Auth
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.postgrest.query.Columns
import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.postgrest.query.filter.TextSearchType
import io.github.jan.supabase.postgrest.rpc

class DefaultRemoteQuestionDataSource(
private val postgrest: Postgrest,
auth: Auth,
) : RemoteQuestionDataSource {
override suspend fun fetchQuestions(): List<QuestionResponse> =
postgrest
.from(TABLE_NAME)
.select(columns = Columns.ALL) {
order(column = ORDER_BY_CREATED_AT, order = Order.DESCENDING)
}.decodeList<QuestionResponse>()
private val uid: String =
auth.currentSessionOrNull()?.user?.id
?: throw IllegalStateException("User not logged in")

override suspend fun fetchQuestions(): List<QuestionResponse> {
val params = mapOf(RPC_FETCH_QUESTIONS_PARAM_NAME to uid)
return postgrest
.rpc(RPC_FETCH_QUESTIONS, params)
.decodeList<QuestionResponse>()
}

override suspend fun fetchQuestionsByCategory(category: String): List<QuestionResponse> =
override suspend fun addLike(questionId: Long) {
val request = LikeRequest(uid, questionId)
postgrest
.from(TABLE_NAME)
.select(columns = Columns.ALL) {
filter {
eq(CATEGORY_COLUMN, category)
}
order(column = ORDER_BY_CREATED_AT, order = Order.DESCENDING)
}.decodeList<QuestionResponse>()
.from(FAVORITES_TABLE)
.insert(request)
}

override suspend fun searchQuestions(query: String): List<QuestionResponse> =
override suspend fun removeLike(questionId: Long) {
postgrest
.from(TABLE_NAME)
.select(columns = Columns.ALL) {
.from(FAVORITES_TABLE)
.delete {
filter {
textSearch(FTS_COLUMN, query, TextSearchType.WEBSEARCH)
eq(USER_ID_COLUMN, uid)
eq(QUESTION_ID_COLUMN, questionId)
}
order(column = ORDER_BY_CREATED_AT, order = Order.DESCENDING)
}.decodeList<QuestionResponse>()
}
}

companion object {
private const val TABLE_NAME = "questions"
private const val CATEGORY_COLUMN = "category"
private const val FTS_COLUMN = "fts"
private const val ORDER_BY_CREATED_AT = "created_at"
private const val RPC_FETCH_QUESTIONS = "fetch_questions"
private const val RPC_FETCH_QUESTIONS_PARAM_NAME = "uid"

private const val FAVORITES_TABLE = "favorites"
private const val USER_ID_COLUMN = "user_id"
private const val QUESTION_ID_COLUMN = "question_id"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import com.peto.droidmorning.data.model.QuestionResponse
interface RemoteQuestionDataSource {
suspend fun fetchQuestions(): List<QuestionResponse>

suspend fun fetchQuestionsByCategory(category: String): List<QuestionResponse>
suspend fun addLike(questionId: Long)

suspend fun searchQuestions(query: String): List<QuestionResponse>
suspend fun removeLike(questionId: Long)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ internal val dataSourceModule =
module {
single<LocalAuthDataSource> { DefaultLocalAuthDataSource(get()) }
single<RemoteAuthDataSource> { DefaultRemoteAuthDataSource(get()) }
single<RemoteQuestionDataSource> { DefaultRemoteQuestionDataSource(get()) }
single<RemoteQuestionDataSource> { DefaultRemoteQuestionDataSource(get(), get()) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.peto.droidmorning.data.model

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class LikeRequest(
@SerialName("user_id")
val userId: String,
@SerialName("question_id")
val questionId: Long,
)
Loading