From b52efed9b4763396496bad8d16ccedaec06927e0 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:16:21 +0900 Subject: [PATCH 01/29] =?UTF-8?q?refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - question → questions/list 이동 (Question 관련 파일들) - data/model → data/model/request, data/model/response 구조화 --- .../list}/QuestionScreen.kt | 18 +++++++++--------- .../list}/component/CategoryChips.kt | 2 +- .../list}/component/EmptyQuestion.kt | 2 +- .../list}/component/QuestionFilterChips.kt | 2 +- .../list}/component/QuestionList.kt | 2 +- .../list/model}/QuestionUiEvent.kt | 2 +- .../list/model}/QuestionUiState.kt | 2 +- .../list}/vm/QuestionViewModel.kt | 2 +- .../data/model/{ => request}/LikeRequest.kt | 2 +- .../model/{ => response}/QuestionResponse.kt | 6 +++--- 10 files changed, 20 insertions(+), 20 deletions(-) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question => questions/list}/QuestionScreen.kt (92%) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question => questions/list}/component/CategoryChips.kt (98%) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question => questions/list}/component/EmptyQuestion.kt (95%) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question => questions/list}/component/QuestionFilterChips.kt (98%) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question => questions/list}/component/QuestionList.kt (99%) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question/vm => questions/list/model}/QuestionUiEvent.kt (81%) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question/vm => questions/list/model}/QuestionUiState.kt (98%) rename composeApp/src/commonMain/kotlin/com/peto/droidmorning/{question => questions/list}/vm/QuestionViewModel.kt (98%) rename data/src/commonMain/kotlin/com/peto/droidmorning/data/model/{ => request}/LikeRequest.kt (82%) rename data/src/commonMain/kotlin/com/peto/droidmorning/data/model/{ => response}/QuestionResponse.kt (88%) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/QuestionScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt similarity index 92% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/QuestionScreen.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt index 5d5e461..90287bd 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/QuestionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question +package com.peto.droidmorning.questions.list import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,13 +23,13 @@ import com.peto.droidmorning.common.ObserveAsEvents import com.peto.droidmorning.designsystem.component.AppSearchBar import com.peto.droidmorning.designsystem.theme.Dimen import com.peto.droidmorning.domain.model.Category -import com.peto.droidmorning.question.component.CategoryChips -import com.peto.droidmorning.question.component.EmptyQuestion -import com.peto.droidmorning.question.component.QuestionFilterChips -import com.peto.droidmorning.question.component.QuestionList -import com.peto.droidmorning.question.vm.QuestionUiEvent -import com.peto.droidmorning.question.vm.QuestionUiState -import com.peto.droidmorning.question.vm.QuestionViewModel +import com.peto.droidmorning.questions.component.CategoryChips +import com.peto.droidmorning.questions.component.EmptyQuestion +import com.peto.droidmorning.questions.component.QuestionFilterChips +import com.peto.droidmorning.questions.component.QuestionList +import com.peto.droidmorning.questions.vm.QuestionUiEvent +import com.peto.droidmorning.questions.vm.QuestionUiState +import com.peto.droidmorning.questions.vm.QuestionViewModel import droidmorning.composeapp.generated.resources.Res import droidmorning.composeapp.generated.resources.question_empty_search import droidmorning.composeapp.generated.resources.question_empty_state @@ -80,7 +80,7 @@ fun QuestionScreen( onToggleCategoryFilters = viewModel::onToggleCategoryFilters, onSolvedFilterToggle = viewModel::onSolvedFilterToggle, onLikedFilterToggle = viewModel::onLikedFilterToggle, - onQuestionClick = {}, + onQuestionClick = onNavigateToDetail, onLikeToggle = viewModel::onLikeToggle, ) } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/CategoryChips.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/CategoryChips.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/CategoryChips.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/CategoryChips.kt index c8eb506..3185d52 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/CategoryChips.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/CategoryChips.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question.component +package com.peto.droidmorning.questions.list.component import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/EmptyQuestion.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/EmptyQuestion.kt similarity index 95% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/EmptyQuestion.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/EmptyQuestion.kt index b70f36e..66faecb 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/EmptyQuestion.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/EmptyQuestion.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question.component +package com.peto.droidmorning.questions.list.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/QuestionFilterChips.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionFilterChips.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/QuestionFilterChips.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionFilterChips.kt index ae77cd9..d3f56f4 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/QuestionFilterChips.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionFilterChips.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question.component +package com.peto.droidmorning.questions.list.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/QuestionList.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionList.kt similarity index 99% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/QuestionList.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionList.kt index 3badd3e..edb3b72 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/component/QuestionList.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionList.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question.component +package com.peto.droidmorning.questions.list.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionUiEvent.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiEvent.kt similarity index 81% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionUiEvent.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiEvent.kt index db3211c..a6d64f0 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionUiEvent.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiEvent.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question.vm +package com.peto.droidmorning.questions.list.model sealed interface QuestionUiEvent { data class NavigateToQuestionDetail( diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionUiState.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt index 9fc4010..884e495 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question.vm +package com.peto.droidmorning.questions.list.model import androidx.compose.runtime.Stable import com.peto.droidmorning.domain.model.Category diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt similarity index 98% rename from composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionViewModel.kt rename to composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt index 72e0fbe..be620de 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/question/vm/QuestionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.question.vm +package com.peto.droidmorning.questions.list.vm import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/LikeRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/LikeRequest.kt similarity index 82% rename from data/src/commonMain/kotlin/com/peto/droidmorning/data/model/LikeRequest.kt rename to data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/LikeRequest.kt index 6104014..6dbe8a6 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/LikeRequest.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/LikeRequest.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.data.model +package com.peto.droidmorning.data.model.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/QuestionResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt similarity index 88% rename from data/src/commonMain/kotlin/com/peto/droidmorning/data/model/QuestionResponse.kt rename to data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt index d8f7eaa..b9be6f3 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/QuestionResponse.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.data.model +package com.peto.droidmorning.data.model.response import com.peto.droidmorning.domain.model.Category import com.peto.droidmorning.domain.model.Question @@ -18,9 +18,9 @@ data class QuestionResponse( @SerialName("updated_at") val updatedAt: Instant, @SerialName("is_favorited") - val isLiked: Boolean = false, + val isLiked: Boolean, @SerialName("is_solved") - val isSolved: Boolean = false, + val isSolved: Boolean, ) { fun toDomain(): Question = Question( From d3ecb785b136c2891c3454b7011e69832bae6194 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:20:42 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20Answer=20sealed=20class=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answer 모델을 sealed class로 설계하여 타입 안전성 강화 - Answer.Current: 현재 답변 (answers_current) - Answer.History: 히스토리 답변 (answer_history) - AnswerRepository 인터페이스 추가 - kotlinx-datetime 의존성 추가 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- domain/build.gradle.kts | 1 + .../peto/droidmorning/domain/model/Answer.kt | 31 +++++++++++++++++++ .../domain/repository/AnswerRepository.kt | 23 ++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Answer.kt create mode 100644 domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/AnswerRepository.kt diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 01876cb..80871b0 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { sourceSets { commonMain.dependencies { + implementation(libs.kotlinx.datetime) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Answer.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Answer.kt new file mode 100644 index 0000000..d18ebb4 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Answer.kt @@ -0,0 +1,31 @@ +package com.peto.droidmorning.domain.model + +import kotlin.time.Instant + +sealed class Answer { + abstract val questionId: Long + abstract val content: String + abstract val createdAt: Instant + abstract val updatedAt: Instant + + data class Current( + val userId: String, + override val questionId: Long, + override val content: String, + override val updatedAt: Instant, + ) : Answer() { + override val createdAt: Instant + get() = updatedAt + } + + data class History( + val id: Long, + val userId: String, + override val questionId: Long, + override val content: String, + override val createdAt: Instant, + ) : Answer() { + override val updatedAt: Instant + get() = createdAt + } +} diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/AnswerRepository.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/AnswerRepository.kt new file mode 100644 index 0000000..6d9f522 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/AnswerRepository.kt @@ -0,0 +1,23 @@ +package com.peto.droidmorning.domain.repository + +import com.peto.droidmorning.domain.model.Answer + +interface AnswerRepository { + suspend fun fetchCurrentAnswer(questionId: Long): Result + + suspend fun fetchAnswerHistory(questionId: Long): Result> + + suspend fun saveAnswer( + questionId: Long, + content: String, + ): Result + + suspend fun updateAnswer( + questionId: Long, + content: String, + ): Result + + suspend fun deleteCurrentAnswer(questionId: Long): Result + + suspend fun deleteAnswerHistory(historyId: Long): Result +} From 1942b6a07033d10679f76ce7d8907be6390c98aa Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:20:50 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat:=20Answer=20Response=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supabase API 응답을 위한 Response 모델 구현 - CurrentAnswerResponse: answers_current 테이블 응답 - AnswerHistoryResponse: answer_history 테이블 응답 - Answer sealed class로 변환하는 toDomain() 메서드 추가 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../model/response/AnswerHistoryResponse.kt | 27 +++++++++++++++++++ .../model/response/CurrentAnswerResponse.kt | 25 +++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/AnswerHistoryResponse.kt create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/AnswerHistoryResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/AnswerHistoryResponse.kt new file mode 100644 index 0000000..f530a4d --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/AnswerHistoryResponse.kt @@ -0,0 +1,27 @@ +package com.peto.droidmorning.data.model.response + +import com.peto.droidmorning.domain.model.Answer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +@Serializable +data class AnswerHistoryResponse( + val id: Long, + @SerialName("user_id") + val userId: String, + @SerialName("question_id") + val questionId: Long, + val content: String, + @SerialName("created_at") + val createdAt: String, +) { + fun toDomain(): Answer.History = + Answer.History( + id = id, + userId = userId, + questionId = questionId, + content = content, + createdAt = Instant.parse(createdAt), + ) +} diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt new file mode 100644 index 0000000..69e12d3 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt @@ -0,0 +1,25 @@ +package com.peto.droidmorning.data.model.response + +import com.peto.droidmorning.domain.model.Answer +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CurrentAnswerResponse( + @SerialName("user_id") + val userId: String, + @SerialName("question_id") + val questionId: Long, + val content: String, + @SerialName("updated_at") + val updatedAt: String, +) { + fun toDomain(): Answer.Current = + Answer.Current( + userId = userId, + questionId = questionId, + content = content, + updatedAt = Instant.parse(updatedAt), + ) +} From 6bc25f1235f5b77bb9ac9eaed9faeafe521e9579 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:21:03 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat:=20Answer=20DataSource=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변 CRUD를 위한 DataSource 구현 **Request 모델:** - CreateAnswerRequest: 답변 생성 요청 - UpdateAnswerRequest: 답변 수정 요청 - RpcDefaultRequest: RPC 호출 공통 요청 **DataSource 구현:** - RemoteAnswerDataSource: 인터페이스 - DefaultRemoteAnswerDataSource: Supabase 구현체 - 답변 생성 시 기존 답변 히스토리 이동 - 답변 삭제 시 최신 히스토리 복원 - 단일 책임 원칙 준수 (헬퍼 함수 분리) 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../remote/DefaultRemoteAnswerDataSource.kt | 97 +++++++++++++++++++ .../answer/remote/RemoteAnswerDataSource.kt | 24 +++++ .../data/model/request/CreateAnswerRequest.kt | 14 +++ .../data/model/request/RpcDefaultRequest.kt | 12 +++ .../data/model/request/UpdateAnswerRequest.kt | 10 ++ 5 files changed, 157 insertions(+) create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/DefaultRemoteAnswerDataSource.kt create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/RemoteAnswerDataSource.kt create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/CreateAnswerRequest.kt create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/RpcDefaultRequest.kt create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/UpdateAnswerRequest.kt diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/DefaultRemoteAnswerDataSource.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/DefaultRemoteAnswerDataSource.kt new file mode 100644 index 0000000..9490903 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/DefaultRemoteAnswerDataSource.kt @@ -0,0 +1,97 @@ +package com.peto.droidmorning.data.datasource.answer.remote + +import com.peto.droidmorning.data.model.request.CreateAnswerRequest +import com.peto.droidmorning.data.model.request.RpcDefaultRequest +import com.peto.droidmorning.data.model.request.UpdateAnswerRequest +import com.peto.droidmorning.data.model.response.AnswerHistoryResponse +import com.peto.droidmorning.data.model.response.CurrentAnswerResponse +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.rpc + +class DefaultRemoteAnswerDataSource( + private val postgrest: Postgrest, + private val auth: Auth, +) : RemoteAnswerDataSource { + override suspend fun fetchCurrentAnswer(questionId: Long): CurrentAnswerResponse? = + postgrest + .from(ANSWERS_CURRENT_TABLE) + .select(Columns.ALL) { + filter { + eq(USER_ID_COLUMN, uid()) + eq(QUESTION_ID_COLUMN, questionId) + } + }.decodeSingleOrNull() + + override suspend fun fetchAnswerHistory(questionId: Long): List = + postgrest + .from(ANSWER_HISTORY_TABLE) + .select(Columns.ALL) { + filter { + eq(USER_ID_COLUMN, uid()) + eq(QUESTION_ID_COLUMN, questionId) + } + order(CREATED_AT_COLUMN, order = Order.DESCENDING) + }.decodeList() + + override suspend fun createAnswer( + questionId: Long, + content: String, + ) { + postgrest.rpc( + function = RPC_UPSERT_ANSWER_CURRENT, + parameters = CreateAnswerRequest(uid(), questionId, content), + ) + } + + override suspend fun modifyAnswer( + questionId: Long, + content: String, + ) { + postgrest + .from(ANSWERS_CURRENT_TABLE) + .update(UpdateAnswerRequest(content)) { + filter { + eq(USER_ID_COLUMN, uid()) + eq(QUESTION_ID_COLUMN, questionId) + } + } + } + + override suspend fun deleteCurrentAnswer(questionId: Long) { + postgrest.rpc( + function = RPC_DELETE_ANSWER_CURRENT, + parameters = RpcDefaultRequest(uid(), questionId), + ) + } + + override suspend fun deleteAnswerHistory(historyId: Long) { + postgrest + .from(ANSWER_HISTORY_TABLE) + .delete { + filter { + eq(ID_COLUMN, historyId) + eq(USER_ID_COLUMN, uid()) + } + } + } + + private fun uid(): String = + auth.currentSessionOrNull()?.user?.id + ?: throw IllegalStateException("User not logged in") + + companion object { + private const val RPC_UPSERT_ANSWER_CURRENT = "upsert_answer_current" + private const val RPC_DELETE_ANSWER_CURRENT = "delete_and_restore_answer" + + private const val ANSWERS_CURRENT_TABLE = "answers_current" + private const val ANSWER_HISTORY_TABLE = "answer_history" + + private const val USER_ID_COLUMN = "user_id" + private const val QUESTION_ID_COLUMN = "question_id" + private const val CREATED_AT_COLUMN = "created_at" + private const val ID_COLUMN = "id" + } +} diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/RemoteAnswerDataSource.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/RemoteAnswerDataSource.kt new file mode 100644 index 0000000..fb79b20 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/answer/remote/RemoteAnswerDataSource.kt @@ -0,0 +1,24 @@ +package com.peto.droidmorning.data.datasource.answer.remote + +import com.peto.droidmorning.data.model.response.AnswerHistoryResponse +import com.peto.droidmorning.data.model.response.CurrentAnswerResponse + +interface RemoteAnswerDataSource { + suspend fun fetchCurrentAnswer(questionId: Long): CurrentAnswerResponse? + + suspend fun fetchAnswerHistory(questionId: Long): List + + suspend fun createAnswer( + questionId: Long, + content: String, + ) + + suspend fun modifyAnswer( + questionId: Long, + content: String, + ) + + suspend fun deleteCurrentAnswer(questionId: Long) + + suspend fun deleteAnswerHistory(historyId: Long) +} diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/CreateAnswerRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/CreateAnswerRequest.kt new file mode 100644 index 0000000..8551dd3 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/CreateAnswerRequest.kt @@ -0,0 +1,14 @@ +package com.peto.droidmorning.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateAnswerRequest( + @SerialName("p_user_id") + val userId: String, + @SerialName("p_question_id") + val questionId: Long, + @SerialName("p_content") + val content: String, +) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/RpcDefaultRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/RpcDefaultRequest.kt new file mode 100644 index 0000000..5abd5f2 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/RpcDefaultRequest.kt @@ -0,0 +1,12 @@ +package com.peto.droidmorning.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class RpcDefaultRequest( + @SerialName("p_user_id") + val userId: String, + @SerialName("p_question_id") + val questionId: Long, +) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/UpdateAnswerRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/UpdateAnswerRequest.kt new file mode 100644 index 0000000..8466a48 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/UpdateAnswerRequest.kt @@ -0,0 +1,10 @@ +package com.peto.droidmorning.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateAnswerRequest( + @SerialName("content") + val content: String, +) From 28e157a2adaac085f66742015d48cc8663546bd3 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:21:13 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat:=20Answer=20Repository=20=EB=B0=8F?= =?UTF-8?q?=20DI=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository 구현 및 Koin 의존성 주입 설정 - DefaultAnswerRepository: AnswerRepository 구현체 - DataSourceModule: RemoteAnswerDataSource 등록 - RepositoryModule: AnswerRepository 등록 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../droidmorning/data/di/DataSourceModule.kt | 3 ++ .../droidmorning/data/di/RepositoryModule.kt | 3 ++ .../repository/DefaultAnswerRepository.kt | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultAnswerRepository.kt diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/DataSourceModule.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/DataSourceModule.kt index ed975de..a39b8db 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/DataSourceModule.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/DataSourceModule.kt @@ -1,5 +1,7 @@ package com.peto.droidmorning.data.di +import com.peto.droidmorning.data.datasource.answer.remote.DefaultRemoteAnswerDataSource +import com.peto.droidmorning.data.datasource.answer.remote.RemoteAnswerDataSource import com.peto.droidmorning.data.datasource.auth.local.DefaultLocalAuthDataSource import com.peto.droidmorning.data.datasource.auth.local.LocalAuthDataSource import com.peto.droidmorning.data.datasource.auth.remote.DefaultRemoteAuthDataSource @@ -13,4 +15,5 @@ internal val dataSourceModule = single { DefaultLocalAuthDataSource(get()) } single { DefaultRemoteAuthDataSource(get()) } single { DefaultRemoteQuestionDataSource(get(), get()) } + single { DefaultRemoteAnswerDataSource(get(), get()) } } diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/RepositoryModule.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/RepositoryModule.kt index 596dae3..84f1ed3 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/RepositoryModule.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/di/RepositoryModule.kt @@ -1,7 +1,9 @@ package com.peto.droidmorning.data.di +import com.peto.droidmorning.data.repository.DefaultAnswerRepository import com.peto.droidmorning.data.repository.DefaultAuthRepository import com.peto.droidmorning.data.repository.DefaultQuestionRepository +import com.peto.droidmorning.domain.repository.AnswerRepository import com.peto.droidmorning.domain.repository.QuestionRepository import com.peto.droidmorning.domain.repository.auth.AuthRepository import org.koin.dsl.module @@ -10,4 +12,5 @@ internal val repositoryModule = module { single { DefaultAuthRepository(get(), get()) } single { DefaultQuestionRepository(get()) } + single { DefaultAnswerRepository(get()) } } diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultAnswerRepository.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultAnswerRepository.kt new file mode 100644 index 0000000..faa2392 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultAnswerRepository.kt @@ -0,0 +1,41 @@ +package com.peto.droidmorning.data.repository + +import com.peto.droidmorning.data.datasource.answer.remote.RemoteAnswerDataSource +import com.peto.droidmorning.domain.model.Answer +import com.peto.droidmorning.domain.repository.AnswerRepository + +class DefaultAnswerRepository( + private val remoteDataSource: RemoteAnswerDataSource, +) : AnswerRepository { + override suspend fun fetchCurrentAnswer(questionId: Long): Result = + runCatching { + remoteDataSource.fetchCurrentAnswer(questionId)?.toDomain() + } + + override suspend fun fetchAnswerHistory(questionId: Long): Result> = + runCatching { + remoteDataSource + .fetchAnswerHistory(questionId) + .map { it.toDomain() } + } + + override suspend fun saveAnswer( + questionId: Long, + content: String, + ): Result = runCatching { remoteDataSource.createAnswer(questionId, content) } + + override suspend fun updateAnswer( + questionId: Long, + content: String, + ): Result = runCatching { remoteDataSource.modifyAnswer(questionId, content) } + + override suspend fun deleteCurrentAnswer(questionId: Long): Result = + runCatching { + remoteDataSource.deleteCurrentAnswer(questionId) + } + + override suspend fun deleteAnswerHistory(historyId: Long): Result = + runCatching { + remoteDataSource.deleteAnswerHistory(historyId) + } +} From 2b809c253918f2a48c72368894511b1614360a9d Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:21:22 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20DateFormatter=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=EB=A6=AC=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instant를 yyyy.MM.dd 형식으로 변환하는 유틸리티 - formatDate(): Instant → "2024.01.15" 변환 - 월/일을 2자리로 패딩하여 일관된 형식 제공 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../droidmorning/common/util/DateFormatter.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt new file mode 100644 index 0000000..bee34e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt @@ -0,0 +1,32 @@ +package com.peto.droidmorning.common.util + +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.number +import kotlinx.datetime.toLocalDateTime + +object DateFormatter { + private const val DATE_SEPARATOR = "." + + fun formatDate( + instant: Instant, + timeZone: TimeZone = TimeZone.currentSystemDefault(), + ): String { + val date = + instant + .toLocalDateTime(timeZone) + .date + + return buildString { + append(date.year) + append(DATE_SEPARATOR) + append( + date.month.number + .toString() + .padStart(2, '0'), + ) + append(DATE_SEPARATOR) + append(date.day.toString().padStart(2, '0')) + } + } +} From 5ccb47102f7396bac40504b45a217ca9bae9a90f Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:21:31 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat:=20AnswerUiModel=20sealed=20class=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI Layer의 타입 안전한 답변 모델 구현 - AnswerUiModel.Current: 현재 답변 UI 모델 - AnswerUiModel.History: 히스토리 답변 UI 모델 - toUiModel(): Domain Answer → UI AnswerUiModel 변환 - Preview Provider 추가 (Preview 데이터 제공) 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../questions/detail/model/AnswerUiModel.kt | 52 ++++++++++++ .../AnswerCardPreviewParameterProvider.kt | 37 +++++++++ .../AnswerHistoryPreviewParameterProvider.kt | 80 +++++++++++++++++++ .../AnswerUiModelPreviewParameterProvider.kt | 33 ++++++++ 4 files changed, 202 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerCardPreviewParameterProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerHistoryPreviewParameterProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerUiModelPreviewParameterProvider.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt new file mode 100644 index 0000000..fab4a37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt @@ -0,0 +1,52 @@ +package com.peto.droidmorning.questions.detail.model + +import com.peto.droidmorning.common.util.DateFormatter +import com.peto.droidmorning.domain.model.Answer + +sealed class AnswerUiModel { + abstract val questionId: Long + abstract val content: String + abstract val createdDate: String + abstract val updatedDate: String + + data class Current( + override val questionId: Long, + override val content: String, + override val createdDate: String, + override val updatedDate: String, + ) : AnswerUiModel() { + val isEditable: Boolean = true + } + + data class History( + val id: Long, + override val questionId: Long, + override val content: String, + override val createdDate: String, + ) : AnswerUiModel() { + override val updatedDate: String + get() = createdDate + } +} + +/** + * Domain Answer를 UI AnswerUiModel로 변환 + */ +fun Answer.toUiModel(): AnswerUiModel = + when (this) { + is Answer.Current -> + AnswerUiModel.Current( + questionId = questionId, + content = content, + createdDate = DateFormatter.formatDate(createdAt), + updatedDate = DateFormatter.formatDate(updatedAt), + ) + + is Answer.History -> + AnswerUiModel.History( + id = id, + questionId = questionId, + content = content, + createdDate = DateFormatter.formatDate(createdAt), + ) + } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerCardPreviewParameterProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerCardPreviewParameterProvider.kt new file mode 100644 index 0000000..3cc8b6d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerCardPreviewParameterProvider.kt @@ -0,0 +1,37 @@ +package com.peto.droidmorning.questions.detail.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.questions.detail.model.AnswerUiModel + +class AnswerCardPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + AnswerUiModel.Current( + questionId = 1L, + content = "lateinit은 var 프로퍼티에만 사용 가능하며, 나중에 초기화할 수 있습니다.", + createdDate = "2024.01.15", + updatedDate = "2024.01.16", + ), + AnswerUiModel.Current( + questionId = 2L, + content = + """ + Kotlin의 data class는 equals(), hashCode(), toString(), copy() 메서드를 자동으로 생성합니다. + + 주요 사항: + 1. data class는 최소 하나의 primary constructor 파라미터가 필요합니다. + 2. primary constructor의 모든 파라미터는 val 또는 var로 선언되어야 합니다. + 3. abstract, open, sealed, inner 클래스가 될 수 없습니다. + 4. copy() 메서드를 통해 불변 객체의 일부 프로퍼티만 변경한 새로운 객체를 쉽게 생성할 수 있습니다. + """.trimIndent(), + createdDate = "2024.01.10", + updatedDate = "2024.01.12", + ), + AnswerUiModel.History( + id = 3L, + questionId = 3L, + content = "val은 불변, var는 가변입니다.", + createdDate = "2024.01.20", + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerHistoryPreviewParameterProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerHistoryPreviewParameterProvider.kt new file mode 100644 index 0000000..6ad46e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerHistoryPreviewParameterProvider.kt @@ -0,0 +1,80 @@ +package com.peto.droidmorning.questions.detail.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +class AnswerHistoryPreviewParameterProvider : PreviewParameterProvider> { + override val values: Sequence> = + sequenceOf( + persistentListOf( + AnswerUiModel.History( + id = 1L, + questionId = 1L, + content = "lateinit은 var 프로퍼티에만 사용 가능하며, 나중에 초기화할 수 있습니다.", + createdDate = "2024.01.10", + ), + ), + persistentListOf( + AnswerUiModel.History( + id = 1L, + questionId = 1L, + content = "lateinit은 나중에 초기화할 수 있습니다.", + createdDate = "2024.01.10", + ), + AnswerUiModel.History( + id = 2L, + questionId = 1L, + content = "lateinit은 var 프로퍼티에만 사용 가능하고, lazy는 val 프로퍼티에 사용됩니다.", + createdDate = "2024.01.12", + ), + AnswerUiModel.History( + id = 3L, + questionId = 1L, + content = + """ + lateinit과 lazy의 주요 차이점: + + 1. lateinit은 var 프로퍼티에만 사용 가능 + 2. lazy는 val 프로퍼티에 사용 + 3. lateinit은 나중에 초기화 가능 + 4. lazy는 처음 접근할 때 자동 초기화 + """.trimIndent(), + createdDate = "2024.01.15", + ), + ), + persistentListOf( + AnswerUiModel.History( + id = 1L, + questionId = 1L, + content = "첫 번째 시도입니다.", + createdDate = "2024.01.01", + ), + AnswerUiModel.History( + id = 2L, + questionId = 1L, + content = "두 번째 시도: lateinit 추가", + createdDate = "2024.01.05", + ), + AnswerUiModel.History( + id = 3L, + questionId = 1L, + content = "세 번째 시도: lazy 추가", + createdDate = "2024.01.10", + ), + AnswerUiModel.History( + id = 4L, + questionId = 1L, + content = "네 번째 시도: 차이점 비교 추가", + createdDate = "2024.01.12", + ), + AnswerUiModel.History( + id = 5L, + questionId = 1L, + content = "다섯 번째 시도: 예제 코드 추가", + createdDate = "2024.01.15", + ), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerUiModelPreviewParameterProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerUiModelPreviewParameterProvider.kt new file mode 100644 index 0000000..18c17cc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/AnswerUiModelPreviewParameterProvider.kt @@ -0,0 +1,33 @@ +package com.peto.droidmorning.questions.detail.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.questions.detail.model.AnswerUiModel + +class AnswerUiModelPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + AnswerUiModel.Current( + questionId = 1L, + content = + "lateinit은 var 프로퍼티에만 사용 가능하며, 나중에 초기화할 수 있습니다. " + + "반면 lazy는 val 프로퍼티에 사용되며, 처음 접근할 때 초기화됩니다.", + createdDate = "2024.01.15", + updatedDate = "2024.01.16", + ), + AnswerUiModel.Current( + questionId = 2L, + content = + """ + Kotlin의 data class는 equals(), hashCode(), toString(), copy() 메서드를 자동으로 생성합니다. + + 주요 사항: + 1. data class는 최소 하나의 primary constructor 파라미터가 필요합니다. + 2. primary constructor의 모든 파라미터는 val 또는 var로 선언되어야 합니다. + 3. abstract, open, sealed, inner 클래스가 될 수 없습니다. + 4. copy() 메서드를 통해 불변 객체의 일부 프로퍼티만 변경한 새로운 객체를 쉽게 생성할 수 있습니다. + """.trimIndent(), + createdDate = "2024.01.10", + updatedDate = "2024.01.12", + ), + ) +} From c3a7e75d1f7e8484072e3f80919811ff54c3c253 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:21:42 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat:=20QuestionDetailUiState=20=EB=B0=8F?= =?UTF-8?q?=20UiEvent=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 상세 화면의 UI 상태 관리 모델 - QuestionDetailUiState: currentAnswer와 historyAnswers 분리 - QuestionDetailUiEvent: UI 이벤트 정의 - draftAnswer 상태로 바텀시트 입력 내용 보존 - QuestionDetailPreviewParameterProvider 추가 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../detail/model/QuestionDetailUiState.kt | 61 ++++++++++ .../QuestionDetailPreviewParameterProvider.kt | 105 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/QuestionDetailPreviewParameterProvider.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiState.kt new file mode 100644 index 0000000..05838b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiState.kt @@ -0,0 +1,61 @@ +package com.peto.droidmorning.questions.detail.model + +import androidx.compose.runtime.Stable +import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.Question +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlin.time.Instant + +@Stable +data class QuestionDetailUiState( + val question: Question, + val currentAnswer: AnswerUiModel.Current?, + val historyAnswers: ImmutableList, + val isLoading: Boolean, + val draftAnswer: String = "", +) { + fun updateQuestion(question: Question): QuestionDetailUiState = copy(question = question) + + fun updateAnswers( + currentAnswer: AnswerUiModel.Current?, + historyAnswers: List, + ): QuestionDetailUiState = + copy( + currentAnswer = currentAnswer, + historyAnswers = historyAnswers.toImmutableList(), + isLoading = false, + ) + + fun toggleFavorite(): QuestionDetailUiState { + val currentQuestion = question + return copy(question = currentQuestion.copy(isLiked = !currentQuestion.isLiked)) + } + + fun loading(isLoading: Boolean): QuestionDetailUiState = copy(isLoading = isLoading) + + fun updateDraftAnswer(content: String): QuestionDetailUiState = copy(draftAnswer = content) + + fun clearDraftAnswer(): QuestionDetailUiState = copy(draftAnswer = "") + + companion object { + fun initial(): QuestionDetailUiState = + QuestionDetailUiState( + question = + Question( + id = 0, + title = "", + category = Category.Kotlin, + sourceUrl = "", + createdAt = Instant.fromEpochMilliseconds(0), + updatedAt = Instant.fromEpochMilliseconds(0), + isSolved = false, + isLiked = false, + ), + currentAnswer = null, + historyAnswers = persistentListOf(), + isLoading = true, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/QuestionDetailPreviewParameterProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/QuestionDetailPreviewParameterProvider.kt new file mode 100644 index 0000000..d5e01c1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/preview/QuestionDetailPreviewParameterProvider.kt @@ -0,0 +1,105 @@ +package com.peto.droidmorning.questions.detail.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.Question +import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.model.QuestionDetailUiState +import kotlinx.collections.immutable.persistentListOf +import kotlin.time.Instant + +class QuestionDetailPreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + QuestionDetailUiState( + question = + Question( + id = 1, + title = "Kotlin의 val과 var의 차이점은 무엇인가요?", + category = Category.Kotlin, + sourceUrl = "", + createdAt = Instant.fromEpochMilliseconds(0), + updatedAt = Instant.fromEpochMilliseconds(0), + isSolved = true, + isLiked = true, + ), + currentAnswer = + AnswerUiModel.Current( + questionId = 1, + content = "val은 불변(immutable) 변수이고, var는 가변(mutable) 변수입니다. val은 초기화 후 값을 변경할 수 없지만, var는 언제든지 값을 변경할 수 있습니다.", + createdDate = "2024.01.01", + updatedDate = "2024.01.05", + ), + historyAnswers = persistentListOf(), + isLoading = false, + ), + QuestionDetailUiState( + question = + Question( + id = 2, + title = "Coroutine의 Dispatcher 종류에 대해 설명해주세요.", + category = Category.Coroutine, + sourceUrl = "", + createdAt = Instant.fromEpochMilliseconds(0), + updatedAt = Instant.fromEpochMilliseconds(0), + isSolved = false, + isLiked = false, + ), + currentAnswer = null, + historyAnswers = persistentListOf(), + isLoading = false, + ), + QuestionDetailUiState( + question = + Question( + id = 3, + title = "lateinit과 lazy의 차이점은 무엇인가요?", + category = Category.Kotlin, + sourceUrl = "", + createdAt = Instant.fromEpochMilliseconds(0), + updatedAt = Instant.fromEpochMilliseconds(0), + isSolved = true, + isLiked = true, + ), + currentAnswer = + AnswerUiModel.Current( + questionId = 3, + content = "lateinit은 var 프로퍼티에만 사용 가능하며, 나중에 초기화할 수 있습니다. lazy는 val 프로퍼티에 사용되며, 처음 접근할 때 초기화됩니다.", + createdDate = "2024.01.01", + updatedDate = "2024.02.01", + ), + historyAnswers = + persistentListOf( + AnswerUiModel.History( + id = 1L, + questionId = 3, + content = "lateinit은 나중에 초기화할 수 있고, lazy는 처음 사용할 때 초기화됩니다.", + createdDate = "2024.01.01", + ), + AnswerUiModel.History( + id = 2L, + questionId = 3, + content = "lateinit은 var에만 사용 가능하고, lazy는 val에 사용됩니다.", + createdDate = "2024.01.15", + ), + ), + isLoading = false, + ), + QuestionDetailUiState( + question = + Question( + id = 0, + title = "", + category = Category.Kotlin, + sourceUrl = "", + createdAt = Instant.fromEpochMilliseconds(0), + updatedAt = Instant.fromEpochMilliseconds(0), + isSolved = false, + isLiked = false, + ), + currentAnswer = null, + historyAnswers = persistentListOf(), + isLoading = true, + ), + ) +} From 6658c3c7769bdd6312e21f1314133c27da91eb90 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:21:53 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20QuestionDetailViewModel=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 상세 화면의 비즈니스 로직 처리 **주요 기능:** - 답변 조회 및 상태 관리 - 답변 추가/수정/삭제 (타입 안전한 sealed class 사용) - draftAnswer 상태 관리 (바텀시트 입력 보존) - 문제 해결 상태 자동 업데이트 - 답변 저장 시 자동 trim 처리 **타입 안전성:** - onDeleteAnswer(AnswerUiModel): sealed class when 표현식 - onUpdateAnswer(AnswerUiModel.Current, String): 타입 명시 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../detail/vm/QuestionDetailViewModel.kt | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt new file mode 100644 index 0000000..2dfae6f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt @@ -0,0 +1,148 @@ +package com.peto.droidmorning.questions.detail.vm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.peto.droidmorning.domain.repository.AnswerRepository +import com.peto.droidmorning.domain.repository.QuestionRepository +import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.model.QuestionDetailUiState +import com.peto.droidmorning.questions.detail.model.toUiModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class QuestionDetailViewModel( + private val questionId: Long, + private val questionRepository: QuestionRepository, + private val answerRepository: AnswerRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(QuestionDetailUiState.initial()) + val uiState = _uiState.asStateFlow() + + init { + loadQuestionDetail() + } + + private fun loadQuestionDetail() { + viewModelScope.launch { + _uiState.update { it.loading(true) } + + questionRepository + .fetchQuestions() + .onSuccess { questions -> + questions.toList().find { it.id == questionId }?.let { + _uiState.update { it.updateQuestion(it.question) } + loadAnswers() + } + } + } + } + + private suspend fun loadAnswers() { + val currentResult = answerRepository.fetchCurrentAnswer(questionId) + val currentAnswer = currentResult.getOrNull() + + val historyResult = answerRepository.fetchAnswerHistory(questionId) + val historyAnswers = historyResult.getOrElse { emptyList() } + + val currentAnswerUi: AnswerUiModel.Current? = + currentAnswer?.toUiModel() as? AnswerUiModel.Current + + val historyAnswersUi: List = + historyAnswers.mapNotNull { it.toUiModel() as? AnswerUiModel.History } + + _uiState.update { it.updateAnswers(currentAnswerUi, historyAnswersUi) } + } + + fun onDraftAnswerChange(content: String) { + _uiState.update { it.updateDraftAnswer(content) } + } + + fun onAddAnswer(content: String) { + if (content.trim().isEmpty()) return + + viewModelScope.launch { + answerRepository + .saveAnswer(questionId, content) + .onSuccess { + _uiState.update { it.clearDraftAnswer() } + loadAnswers() + updateQuestionSolvedStatus(true) + } + } + } + + fun onUpdateAnswer( + answer: AnswerUiModel.Current, + content: String, + ) { + if (content.trim().isEmpty()) return + + viewModelScope.launch { + answerRepository + .updateAnswer(answer.questionId, content) + .onSuccess { + loadAnswers() + } + } + } + + fun onDeleteAnswer(answer: AnswerUiModel) { + viewModelScope.launch { + when (answer) { + is AnswerUiModel.History -> { + // 히스토리 답변 삭제 + answerRepository + .deleteAnswerHistory(answer.id) + .onSuccess { + loadAnswers() + } + } + + is AnswerUiModel.Current -> { + // 현재 답변 삭제 + answerRepository + .deleteCurrentAnswer(questionId) + .onSuccess { + // loadAnswers()가 완료될 때까지 기다린 후 상태 확인 + viewModelScope.launch { + loadAnswers() + + // loadAnswers() 완료 후 히스토리에서 복원된 답변이 있는지 확인 + val hasAnswerAfterDelete = _uiState.value.currentAnswer != null + if (!hasAnswerAfterDelete) { + // 모든 답변이 삭제되었으면 미해결 상태로 변경 + updateQuestionSolvedStatus(false) + } + } + } + } + } + } + } + + fun onToggleFavorite() { + viewModelScope.launch { + val currentQuestion = _uiState.value.question + val isCurrentlyLiked = currentQuestion.isLiked + + _uiState.update { it.toggleFavorite() } + + questionRepository + .toggleQuestionLike(questionId, isCurrentlyLiked) + .onFailure { + _uiState.update { it.toggleFavorite() } + } + } + } + + private fun updateQuestionSolvedStatus(isSolved: Boolean) { + viewModelScope.launch { + val currentQuestion = _uiState.value.question + _uiState.update { + it.updateQuestion(currentQuestion.copy(isSolved = isSolved)) + } + } + } +} From 72d3be8c10d8e889cb6135f21e62352446aa8177 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:22:04 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20Design=20System=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재사용 가능한 디자인 시스템 컴포넌트 추가 **신규 컴포넌트:** - ConfirmDialog: 확인 다이얼로그 (아이콘, 제목, 메시지, 버튼) - Modifier.conditionalBorder: 조건부 border 적용 확장 함수 **개선사항:** - AppTextArea: minLines 파라미터 추가 (기본 3줄) - AppPrimaryButton: enabled 상태 지원 - Dimen: textFieldHeightLarge 추가 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../component/AppButtonDefaults.kt | 23 +-- .../component/AppPrimaryButton.kt | 21 ++- .../designsystem/component/AppTextArea.kt | 3 + .../designsystem/component/ConfirmDialog.kt | 161 ++++++++++++++++++ .../extension/CategoryExtensions.kt | 19 +++ .../droidmorning/designsystem/theme/Dimen.kt | 3 +- 6 files changed, 205 insertions(+), 25 deletions(-) create mode 100644 designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmDialog.kt create mode 100644 designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/extension/CategoryExtensions.kt diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppButtonDefaults.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppButtonDefaults.kt index 515a208..2b058ba 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppButtonDefaults.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppButtonDefaults.kt @@ -2,11 +2,11 @@ package com.peto.droidmorning.designsystem.component import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import com.peto.droidmorning.designsystem.theme.Dimen import com.peto.droidmorning.designsystem.theme.Shape @@ -28,20 +28,9 @@ object AppButtonDefaults { ) @Composable - fun primaryButtonBackgroundBrush(enabled: Boolean = true): Brush { - val alpha = if (enabled) 1f else BACKGROUND_ALPHA - val primaryLight = MaterialTheme.colorScheme.primaryContainer - val primary = MaterialTheme.colorScheme.primary - val primaryOrange = MaterialTheme.colorScheme.tertiary - - return Brush.horizontalGradient( - colorStops = - arrayOf( - 0.0f to primaryLight.copy(alpha = alpha), - 0.5f to primary.copy(alpha = alpha), - 1.0f to primaryOrange.copy(alpha = alpha), - ), - ) + fun primaryButtonBackgroundColor(enabled: Boolean = true): Color { + val alpha = if (enabled) 1f else 0.5f + return MaterialTheme.colorScheme.primary.copy(alpha = alpha) } @Composable @@ -73,4 +62,8 @@ object AppButtonDefaults { val iconSize: Dp = Dimen.iconSm val iconSpacing: Dp = Dimen.spacingSm + + val elevation: Dp = Dimen.cardElevation + + val pressedElevation: Dp = 4.dp } diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppPrimaryButton.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppPrimaryButton.kt index c401d7a..adf95c5 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppPrimaryButton.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppPrimaryButton.kt @@ -1,6 +1,5 @@ package com.peto.droidmorning.designsystem.component -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -12,10 +11,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.peto.droidmorning.designsystem.theme.AppTheme @Composable @@ -27,24 +25,29 @@ fun AppPrimaryButton( icon: ImageVector? = null, ) { val shape = AppButtonDefaults.shape - val backgroundBrush = AppButtonDefaults.primaryButtonBackgroundBrush(enabled = enabled) + val backgroundColor = AppButtonDefaults.primaryButtonBackgroundColor(enabled = enabled) Button( onClick = onClick, modifier = modifier .fillMaxWidth() - .height(AppButtonDefaults.height) - .clip(shape) - .background(backgroundBrush), + .height(AppButtonDefaults.height), enabled = enabled, + shape = shape, colors = ButtonDefaults.buttonColors( - containerColor = Color.Transparent, + containerColor = backgroundColor, contentColor = AppButtonDefaults.primaryContentColor(enabled = true), - disabledContainerColor = Color.Transparent, + disabledContainerColor = backgroundColor, disabledContentColor = AppButtonDefaults.primaryContentColor(enabled = false), ), + elevation = + ButtonDefaults.buttonElevation( + defaultElevation = AppButtonDefaults.elevation, + pressedElevation = AppButtonDefaults.pressedElevation, + disabledElevation = 0.dp, + ), ) { if (icon != null) { Icon( diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppTextArea.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppTextArea.kt index 21752f4..f58e4f0 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppTextArea.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/AppTextArea.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField @@ -47,6 +48,7 @@ fun AppTextArea( isError: Boolean = false, errorMessage: String? = null, maxCharacters: Int? = null, + minLines: Int = 3, keyboardOptions: KeyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, @@ -86,6 +88,7 @@ fun AppTextArea( modifier = Modifier .fillMaxWidth() + .heightIn(min = (minLines * 24).dp) .clip(Shape.inputField) .background(MaterialTheme.colorScheme.secondary) .border(1.dp, borderColor, Shape.inputField) diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmDialog.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmDialog.kt new file mode 100644 index 0000000..c0b5986 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmDialog.kt @@ -0,0 +1,161 @@ +package com.peto.droidmorning.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.designsystem.theme.Shape + +@Composable +fun ConfirmDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + title: String, + message: String, + confirmText: String, + cancelText: String, + icon: ImageVector = Icons.Default.Error, + iconTint: Color = MaterialTheme.colorScheme.error, + iconBackgroundColor: Color = MaterialTheme.colorScheme.errorContainer, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + ConfirmDialogContent( + title = title, + message = message, + confirmText = confirmText, + cancelText = cancelText, + icon = icon, + iconTint = iconTint, + iconBackgroundColor = iconBackgroundColor, + onConfirm = onConfirm, + onCancel = onDismissRequest, + ) + } +} + +@Composable +private fun ConfirmDialogContent( + title: String, + message: String, + confirmText: String, + cancelText: String, + icon: ImageVector, + iconTint: Color, + iconBackgroundColor: Color, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + Surface( + modifier = + Modifier + .fillMaxWidth(0.85f) + .clip(Shape.card), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(Dimen.spacingXl), + ) { + Box( + modifier = + Modifier + .size(72.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(iconBackgroundColor), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(40.dp), + ) + } + + Spacer(modifier = Modifier.height(Dimen.spacingLg)) + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(Dimen.spacingSm)) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(Dimen.spacingXl)) + + Row( + modifier = Modifier.fillMaxWidth(), + ) { + AppSecondaryButton( + text = cancelText, + onClick = onCancel, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.width(Dimen.spacingSm)) + + AppPrimaryButton( + text = confirmText, + onClick = onConfirm, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Preview +@Composable +private fun ConfirmDialogPreview() { + AppTheme { + ConfirmDialogContent( + title = "답변 삭제", + message = "정말 이 답변을 삭제하시겠습니까?", + confirmText = "삭제", + cancelText = "취소", + icon = Icons.Default.Error, + iconTint = MaterialTheme.colorScheme.error, + iconBackgroundColor = MaterialTheme.colorScheme.errorContainer, + onConfirm = {}, + onCancel = {}, + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/extension/CategoryExtensions.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/extension/CategoryExtensions.kt new file mode 100644 index 0000000..9bf72c5 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/extension/CategoryExtensions.kt @@ -0,0 +1,19 @@ +package com.peto.droidmorning.designsystem.extension + +import androidx.compose.ui.graphics.Color +import com.peto.droidmorning.designsystem.theme.CategoryAndroid +import com.peto.droidmorning.designsystem.theme.CategoryCompose +import com.peto.droidmorning.designsystem.theme.CategoryCoroutine +import com.peto.droidmorning.designsystem.theme.CategoryKotlin +import com.peto.droidmorning.designsystem.theme.CategoryOOP +import com.peto.droidmorning.domain.model.Category + +val Category.color: Color + get() = + when (this) { + Category.Kotlin -> CategoryKotlin + Category.Compose -> CategoryCompose + Category.Coroutine -> CategoryCoroutine + Category.Android -> CategoryAndroid + Category.OOP -> CategoryOOP + } diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Dimen.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Dimen.kt index 1830d25..780777f 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Dimen.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Dimen.kt @@ -18,11 +18,12 @@ object Dimen { val buttonHeightSm = 36.dp val buttonHeightMd = 44.dp - val buttonHeightLg = 48.dp + val buttonHeightLg = 52.dp val buttonHeightXl = 56.dp val inputHeight = 48.dp val searchBarHeight = 48.dp + val textFieldHeightLarge = 200.dp val bottomNavHeight = 64.dp val bottomNavItemSize = 56.dp From 1ced23ee0adaa66bd26fbd215419d00f88fbdcff Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:22:14 +0900 Subject: [PATCH 11/29] =?UTF-8?q?feat:=20AnswerCard=20=EB=B0=8F=20EmptyAns?= =?UTF-8?q?werCard=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변 표시를 위한 기본 카드 컴포넌트 **AnswerCard:** - sealed class 타입에 따라 날짜 표시 분기 (when 표현식) - Current: "마지막 수정: yyyy.MM.dd" - History: "yyyy.MM.dd" - 수정/삭제 버튼 옵션 **EmptyAnswerCard:** - 답변이 없을 때 표시하는 빈 카드 - 안내 메시지 제공 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../questions/detail/component/AnswerCard.kt | 137 ++++++++++++++++++ .../detail/component/EmptyAnswerCard.kt | 48 ++++++ 2 files changed, 185 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EmptyAnswerCard.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt new file mode 100644 index 0000000..b5ef75f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt @@ -0,0 +1,137 @@ +package com.peto.droidmorning.questions.detail.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.preview.AnswerCardPreviewParameterProvider +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.delete +import droidmorning.composeapp.generated.resources.edit +import droidmorning.composeapp.generated.resources.last_modified_prefix +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AnswerCard( + answer: AnswerUiModel, + modifier: Modifier = Modifier, + onEdit: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, +) { + val displayDate = + when (answer) { + is AnswerUiModel.Current -> answer.updatedDate + is AnswerUiModel.History -> answer.createdDate + } + + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = + Modifier + .padding(horizontal = Dimen.spacingBase) + .padding(top = Dimen.spacingLg, bottom = Dimen.spacingSm), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingMd), + ) { + Text( + text = answer.content, + style = MaterialTheme.typography.bodyLarge, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + if (answer is AnswerUiModel.Current) { + Row(horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXxs)) { + Text( + text = stringResource(Res.string.last_modified_prefix), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = displayDate, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Text( + text = displayDate, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (onEdit != null || onDelete != null) { + Row(horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXxs)) { + onEdit?.let { + IconButton( + onClick = onEdit, + modifier = Modifier.size(Dimen.iconLg), + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = stringResource(Res.string.edit), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(Dimen.iconSm), + ) + } + } + + onDelete?.let { + IconButton( + onClick = onDelete, + modifier = Modifier.size(Dimen.iconLg), + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringResource(Res.string.delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(Dimen.iconSm), + ) + } + } + } + } + } + } + } +} + +@Preview +@Composable +private fun AnswerCardPreview( + @PreviewParameter(AnswerCardPreviewParameterProvider::class) + answer: AnswerUiModel, +) { + AppTheme { + AnswerCard( + answer = answer, + onEdit = {}, + onDelete = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EmptyAnswerCard.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EmptyAnswerCard.kt new file mode 100644 index 0000000..46f5bf2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EmptyAnswerCard.kt @@ -0,0 +1,48 @@ +package com.peto.droidmorning.questions.detail.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.no_answer_yet +import org.jetbrains.compose.resources.stringResource + +@Composable +fun EmptyAnswerCard(modifier: Modifier = Modifier) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = MaterialTheme.shapes.medium, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimen.spacing2xl), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.no_answer_yet), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EmptyAnswerCardPreview() { + AppTheme { + EmptyAnswerCard() + } +} From fa2aa8cb70c174ef70c7ba3407beda9ee228194f Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:22:23 +0900 Subject: [PATCH 12/29] =?UTF-8?q?feat:=20EditAnswerCard=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변 수정을 위한 인라인 편집 카드 - AppTextArea 사용한 답변 편집 - 저장/취소 버튼 - 현재 답변 수정 시 히스토리에 추가되지 않고 업데이트 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../detail/component/EditAnswerCard.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EditAnswerCard.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EditAnswerCard.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EditAnswerCard.kt new file mode 100644 index 0000000..67ee32b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/EditAnswerCard.kt @@ -0,0 +1,111 @@ +package com.peto.droidmorning.questions.detail.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.component.AppPrimaryButton +import com.peto.droidmorning.designsystem.component.AppTextArea +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.answer_placeholder +import droidmorning.composeapp.generated.resources.cancel +import droidmorning.composeapp.generated.resources.save +import org.jetbrains.compose.resources.stringResource + +@Composable +fun EditAnswerCard( + content: String, + onContentChange: (String) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = Modifier.padding(Dimen.spacingBase), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingMd), + ) { + AppTextArea( + value = content, + onValueChange = onContentChange, + placeholder = stringResource(Res.string.answer_placeholder), + modifier = + Modifier + .fillMaxWidth() + .height(Dimen.spacing5xl * 2), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onCancel) { + Text( + stringResource(Res.string.cancel), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.width(Dimen.spacingSm)) + + AppPrimaryButton( + text = stringResource(Res.string.save), + onClick = onSave, + enabled = content.isNotEmpty(), + modifier = Modifier.width(80.dp), + ) + } + } + } +} + +@Preview +@Composable +private fun EditAnswerCardPreview() { + var content by remember { mutableStateOf("") } + AppTheme { + EditAnswerCard( + content = content, + onContentChange = { }, + onSave = {}, + onCancel = {}, + ) + } +} + +@Preview +@Composable +private fun EditAnswerCardWithContentPreview() { + AppTheme { + EditAnswerCard( + content = "lateinit은 var 프로퍼티에만 사용 가능하며, 나중에 초기화할 수 있습니다.", + onContentChange = { }, + onSave = {}, + onCancel = {}, + ) + } +} From bdf1ff4acd747bb6156fcea6c04087f84ca1518d Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:22:31 +0900 Subject: [PATCH 13/29] =?UTF-8?q?feat:=20MyAnswer=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 답변 섹션 컴포넌트 **타입 안전성:** - answer: AnswerUiModel.Current? (nullable) - onUpdateAnswer: (AnswerUiModel.Current, String) - 타입 명시 - onDeleteAnswer: (AnswerUiModel.Current) - 타입 명시 **기능:** - 답변 없음: EmptyAnswerCard 표시 - 일반 모드: AnswerCard 표시 - 수정 모드: EditAnswerCard 표시 - 삭제 확인 다이얼로그 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../questions/detail/component/MyAnswer.kt | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/MyAnswer.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/MyAnswer.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/MyAnswer.kt new file mode 100644 index 0000000..7a46eb7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/MyAnswer.kt @@ -0,0 +1,136 @@ +package com.peto.droidmorning.questions.detail.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.peto.droidmorning.designsystem.component.ConfirmDialog +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.preview.AnswerUiModelPreviewParameterProvider +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.cancel +import droidmorning.composeapp.generated.resources.delete +import droidmorning.composeapp.generated.resources.delete_answer_confirm_message +import droidmorning.composeapp.generated.resources.delete_answer_title +import droidmorning.composeapp.generated.resources.my_answer +import org.jetbrains.compose.resources.stringResource + +@Composable +fun MyAnswer( + answer: AnswerUiModel.Current?, + onUpdateAnswer: (AnswerUiModel.Current, String) -> Unit, + onDeleteAnswer: (AnswerUiModel.Current) -> Unit, + modifier: Modifier = Modifier, +) { + var isEditing by remember { mutableStateOf(false) } + var editContent by remember { mutableStateOf("") } + var showDeleteConfirm by remember { mutableStateOf(false) } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingMd), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXs), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(Dimen.iconMd), + ) + Text( + text = stringResource(Res.string.my_answer), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + when (answer) { + null -> EmptyAnswerCard() + else -> { + when { + isEditing -> { + EditAnswerCard( + content = editContent, + onContentChange = { editContent = it }, + onSave = { + if (editContent.trim().isNotEmpty()) { + onUpdateAnswer(answer, editContent) + isEditing = false + } + }, + onCancel = { + isEditing = false + editContent = "" + }, + ) + } + + else -> { + AnswerCard( + answer = answer, + onEdit = { + isEditing = true + editContent = answer.content + }, + onDelete = { showDeleteConfirm = true }, + ) + } + } + + if (showDeleteConfirm) { + ConfirmDialog( + onDismissRequest = { showDeleteConfirm = false }, + onConfirm = { + onDeleteAnswer(answer) + showDeleteConfirm = false + }, + title = stringResource(Res.string.delete_answer_title), + message = stringResource(Res.string.delete_answer_confirm_message), + confirmText = stringResource(Res.string.delete), + cancelText = stringResource(Res.string.cancel), + icon = Icons.Outlined.Delete, + iconTint = MaterialTheme.colorScheme.error, + iconBackgroundColor = MaterialTheme.colorScheme.errorContainer, + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MyAnswerPreview( + @PreviewParameter(AnswerUiModelPreviewParameterProvider::class) + answer: AnswerUiModel.Current, +) { + AppTheme { + MyAnswer( + answer = answer, + onUpdateAnswer = { _, _ -> }, + onDeleteAnswer = {}, + ) + } +} From 0197320319d5b7a3079e24c4c20cc2d551c51255 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:22:40 +0900 Subject: [PATCH 14/29] =?UTF-8?q?feat:=20AnswerHistory=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변 히스토리 섹션 컴포넌트 **타입 안전성:** - historyAnswers: ImmutableList - onDeleteAnswer: (AnswerUiModel.History) - 타입 명시 - AnswerCard 재사용 (onEdit = null로 읽기 전용) **기능:** - 히스토리 개수 표시 - 타임라인 시각화 - 각 히스토리 삭제 가능 - 히스토리는 읽기 전용 (수정 불가) 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../detail/component/AnswerHistory.kt | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt new file mode 100644 index 0000000..08661b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt @@ -0,0 +1,182 @@ +package com.peto.droidmorning.questions.detail.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.History +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.peto.droidmorning.designsystem.component.ConfirmDialog +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.preview.AnswerHistoryPreviewParameterProvider +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.answer_history_count +import droidmorning.composeapp.generated.resources.cancel +import droidmorning.composeapp.generated.resources.delete +import droidmorning.composeapp.generated.resources.delete_answer_confirm_message +import droidmorning.composeapp.generated.resources.delete_answer_title +import kotlinx.collections.immutable.ImmutableList +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AnswerHistory( + historyAnswers: ImmutableList, + onDeleteAnswer: (AnswerUiModel.History) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingMd), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimen.spacingSm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(Dimen.iconMd), + ) + Text( + text = stringResource(Res.string.answer_history_count, historyAnswers.size), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "(${historyAnswers.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = Dimen.spacingBase), + ) { + historyAnswers.forEachIndexed { index, answer -> + HistoryItem( + answer = answer, + isLast = index == historyAnswers.size - 1, + onDeleteAnswer = onDeleteAnswer, + ) + } + } + } +} + +@Composable +private fun HistoryItem( + answer: AnswerUiModel.History, + isLast: Boolean, + onDeleteAnswer: (AnswerUiModel.History) -> Unit, +) { + var showDeleteConfirm by remember { mutableStateOf(false) } + + val borderColor = MaterialTheme.colorScheme.outline + val dotSize = Dimen.spacingSm + + Box( + modifier = + Modifier + .fillMaxWidth() + .drawBehind { + val dotSizePx = dotSize.toPx() + val centerX = dotSizePx / 2f + val lineStartY = dotSizePx / 2f + drawLine( + color = borderColor, + start = Offset(centerX, lineStartY), + end = Offset(centerX, size.height), + strokeWidth = 2f, + ) + }, + ) { + Box( + modifier = + Modifier + .size(dotSize) + .background( + MaterialTheme.colorScheme.onSurfaceVariant, + MaterialTheme.shapes.extraSmall, + ).align(Alignment.TopStart), + ) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(start = Dimen.spacingLg), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingSm), + ) { + AnswerCard( + answer = answer, + onEdit = null, + onDelete = { showDeleteConfirm = true }, + ) + + if (!isLast) { + Spacer(modifier = Modifier.height(Dimen.spacingMd)) + } + } + + if (showDeleteConfirm) { + ConfirmDialog( + onDismissRequest = { showDeleteConfirm = false }, + onConfirm = { + onDeleteAnswer(answer) + showDeleteConfirm = false + }, + title = stringResource(Res.string.delete_answer_title), + message = stringResource(Res.string.delete_answer_confirm_message), + confirmText = stringResource(Res.string.delete), + cancelText = stringResource(Res.string.cancel), + icon = Icons.Outlined.Delete, + iconTint = MaterialTheme.colorScheme.error, + iconBackgroundColor = MaterialTheme.colorScheme.errorContainer, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun AnswerHistoryPreview( + @PreviewParameter(AnswerHistoryPreviewParameterProvider::class) + historyAnswers: ImmutableList, +) { + AppTheme { + AnswerHistory( + historyAnswers = historyAnswers, + onDeleteAnswer = {}, + ) + } +} From c7ce082b9ae91bc9542c615c7c4732579c88a394 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:22:50 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20AddAnswerBottomSheet=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 답변 추가를 위한 바텀시트 컴포넌트 **특징:** - ModalBottomSheet 사용 - draftAnswer 상태로 입력 내용 보존 - 취소 시에도 draft 유지 - 저장 시에만 draft 초기화 - 키보드 대응 (imePadding, navigationBarsPadding) **UX 개선:** - 실수로 닫아도 작성 내용 유지 - 재오픈 시 이전 내용 표시 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../detail/component/AddAnswerBottomSheet.kt | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AddAnswerBottomSheet.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AddAnswerBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AddAnswerBottomSheet.kt new file mode 100644 index 0000000..dd0bf4f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AddAnswerBottomSheet.kt @@ -0,0 +1,175 @@ +package com.peto.droidmorning.questions.detail.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.add_answer_placeholder +import droidmorning.composeapp.generated.resources.add_answer_title +import droidmorning.composeapp.generated.resources.cancel +import droidmorning.composeapp.generated.resources.save +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddAnswerBottomSheet( + draftAnswer: String, + onDraftAnswerChange: (String) -> Unit, + onDismiss: () -> Unit, + onSave: (String) -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), +) { + AddAnswerBottomSheetContent( + draftAnswer = draftAnswer, + onDraftAnswerChange = onDraftAnswerChange, + onDismiss = onDismiss, + onSave = onSave, + modifier = modifier, + sheetState = sheetState, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddAnswerBottomSheetContent( + draftAnswer: String, + onDraftAnswerChange: (String) -> Unit, + onDismiss: () -> Unit, + onSave: (String) -> Unit, + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), +) { + val scope = rememberCoroutineScope() + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .imePadding() + .padding(horizontal = Dimen.spacingLg) + .padding(bottom = Dimen.spacingLg), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Res.string.add_answer_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXs)) { + TextButton( + onClick = { + scope.launch { + sheetState.hide() + onDismiss() + } + }, + ) { + Text(stringResource(Res.string.cancel)) + } + + TextButton( + onClick = { + if (draftAnswer.trim().isNotEmpty()) { + scope.launch { + onSave(draftAnswer) + sheetState.hide() + onDismiss() + } + } + }, + enabled = draftAnswer.trim().isNotEmpty(), + ) { + Text(stringResource(Res.string.save)) + } + } + } + + Spacer(modifier = Modifier.height(Dimen.spacingMd)) + + TextField( + value = draftAnswer, + onValueChange = onDraftAnswerChange, + modifier = + Modifier + .fillMaxWidth() + .height(Dimen.textFieldHeightLarge), + placeholder = { + Text( + text = stringResource(Res.string.add_answer_placeholder), + style = MaterialTheme.typography.bodyLarge, + ) + }, + textStyle = MaterialTheme.typography.bodyLarge, + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Default, + ), + shape = MaterialTheme.shapes.medium, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun AddAnswerBottomSheetPreview() { + AppTheme { + AddAnswerBottomSheetContent( + draftAnswer = "", + onDraftAnswerChange = {}, + onDismiss = {}, + onSave = {}, + ) + } +} From 0a92c3efef90061298c28a36955799afb5c50317 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:23:00 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat:=20QuestionInfo=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 정보를 표시하는 컴포넌트 - 문제 제목 표시 - 카테고리 뱃지 - 해결 상태 뱃지 - CategoryExtensions 활용한 카테고리 색상 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../detail/component/QuestionInfo.kt | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/QuestionInfo.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/QuestionInfo.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/QuestionInfo.kt new file mode 100644 index 0000000..15d4f22 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/QuestionInfo.kt @@ -0,0 +1,97 @@ +package com.peto.droidmorning.questions.detail.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.peto.droidmorning.designsystem.component.CategoryBadge +import com.peto.droidmorning.designsystem.extension.color +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.domain.model.Category +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.answer_completed +import org.jetbrains.compose.resources.stringResource + +@Composable +fun QuestionInfo( + title: String, + category: Category, + isSolved: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingMd), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimen.spacingSm), + verticalAlignment = Alignment.CenterVertically, + ) { + CategoryBadge( + category = category, + categoryColor = category.color, + ) + + if (isSolved) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small, + ) { + Row( + modifier = + Modifier.padding( + horizontal = Dimen.badgePaddingHorizontal, + vertical = Dimen.badgePaddingVertical, + ), + horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXxs), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(Dimen.iconXs), + ) + Text( + text = stringResource(Res.string.answer_completed), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun QuestionInfoSolvedPreview() { + AppTheme { + QuestionInfo( + title = "Coroutine의 Dispatcher 종류에 대해 설명해주세요.", + category = Category.Coroutine, + isSolved = true, + ) + } +} From ebe48b425254f0ff573df1c6a173c1047d09b6cb Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:23:09 +0900 Subject: [PATCH 17/29] =?UTF-8?q?feat:=20QuestionDetailScreen=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 상세 화면 메인 컴포저블 **주요 구성:** - QuestionInfo: 문제 정보 - MyAnswer: 내 답변 섹션 - AnswerHistory: 히스토리 섹션 - AddAnswerBottomSheet: 답변 추가 - 즐겨찾기 토글 버튼 **타입 안전성:** - onUpdateAnswer: (AnswerUiModel.Current, String) - onDeleteAnswer: (AnswerUiModel) - sealed class when 표현식으로 타입 분기 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../questions/detail/QuestionDetailScreen.kt | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt new file mode 100644 index 0000000..c7d8054 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt @@ -0,0 +1,216 @@ +package com.peto.droidmorning.questions.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.peto.droidmorning.designsystem.component.AppPrimaryButton +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.questions.detail.component.AddAnswerBottomSheet +import com.peto.droidmorning.questions.detail.component.AnswerHistory +import com.peto.droidmorning.questions.detail.component.MyAnswer +import com.peto.droidmorning.questions.detail.component.QuestionInfo +import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.model.QuestionDetailUiState +import com.peto.droidmorning.questions.detail.preview.QuestionDetailPreviewParameterProvider +import com.peto.droidmorning.questions.detail.vm.QuestionDetailViewModel +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.add_answer +import droidmorning.composeapp.generated.resources.back +import droidmorning.composeapp.generated.resources.favorite +import droidmorning.composeapp.generated.resources.question_detail_title +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QuestionDetailScreen( + questionId: Long, + onNavigateBack: () -> Unit, + viewModel: QuestionDetailViewModel = koinViewModel { parametersOf(questionId) }, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var showAddAnswerSheet by remember { mutableStateOf(false) } + + QuestionDetailScreenContent( + uiState = uiState, + onNavigateBack = onNavigateBack, + onToggleFavorite = viewModel::onToggleFavorite, + onShowAddAnswerSheet = { showAddAnswerSheet = true }, + onUpdateAnswer = { answer, content -> viewModel.onUpdateAnswer(answer, content) }, + onDeleteAnswer = { answer -> viewModel.onDeleteAnswer(answer) }, + ) + + if (showAddAnswerSheet) { + AddAnswerBottomSheet( + draftAnswer = uiState.draftAnswer, + onDraftAnswerChange = viewModel::onDraftAnswerChange, + onDismiss = { showAddAnswerSheet = false }, + onSave = { content -> + viewModel.onAddAnswer(content) + showAddAnswerSheet = false + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun QuestionDetailScreenContent( + uiState: QuestionDetailUiState, + onNavigateBack: () -> Unit, + onShowAddAnswerSheet: () -> Unit, + onToggleFavorite: () -> Unit, + onUpdateAnswer: (AnswerUiModel.Current, String) -> Unit, + onDeleteAnswer: (AnswerUiModel) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.question_detail_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + actions = { + val question = uiState.question + IconButton(onClick = onToggleFavorite) { + Icon( + imageVector = + if (question.isLiked) { + Icons.Filled.Star + } else { + Icons.Filled.StarBorder + }, + contentDescription = stringResource(Res.string.favorite), + tint = + if (question.isLiked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + }, + ) + }, + bottomBar = { + AppPrimaryButton( + text = stringResource(Res.string.add_answer), + onClick = onShowAddAnswerSheet, + modifier = + Modifier + .fillMaxWidth() + .padding(Dimen.spacingLg), + icon = Icons.Filled.Edit, + ) + }, + ) { paddingValues -> + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + else -> { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(vertical = Dimen.spacingBase), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingXl), + ) { + QuestionInfo( + title = uiState.question.title, + category = uiState.question.category, + isSolved = uiState.question.isSolved, + modifier = Modifier.padding(horizontal = Dimen.spacingBase), + ) + + HorizontalDivider() + + MyAnswer( + answer = uiState.currentAnswer, + onUpdateAnswer = onUpdateAnswer, + onDeleteAnswer = { onDeleteAnswer(it) }, + modifier = Modifier.padding(horizontal = Dimen.spacingBase), + ) + + if (uiState.historyAnswers.isNotEmpty()) { + HorizontalDivider() + + AnswerHistory( + historyAnswers = uiState.historyAnswers, + onDeleteAnswer = { onDeleteAnswer(it) }, + modifier = Modifier.padding(horizontal = Dimen.spacingBase), + ) + } + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun QuestionDetailScreenContentPreview( + @PreviewParameter(QuestionDetailPreviewParameterProvider::class) + uiState: QuestionDetailUiState, +) { + AppTheme { + QuestionDetailScreenContent( + uiState = uiState, + onNavigateBack = {}, + onShowAddAnswerSheet = {}, + onToggleFavorite = {}, + onUpdateAnswer = { _, _ -> }, + onDeleteAnswer = {}, + ) + } +} From ad23e4a2411f7de973977e4e65765f883cae4dfd Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:23:18 +0900 Subject: [PATCH 18/29] =?UTF-8?q?feat:=20QuestionDetail=20Navigation=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 상세 화면 네비게이션 구성 - QuestionDetailNavGraph: 상세 화면 네비게이션 그래프 - NavRoutes.QuestionDetail 추가 - MainNavGraphContributor에 통합 - Type-safe navigation 적용 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../navigation/MainNavGraphContributor.kt | 6 ++- .../peto/droidmorning/navigation/NavRoutes.kt | 19 +++++++++- .../navigation/QuestionDetailNavGraph.kt | 38 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt index 6dec92e..14f8d13 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt @@ -21,7 +21,11 @@ class MainNavGraphContributor : NavGraphContributor { startDestination = startDestination, ) { composable(NavRoutes.Main.route) { - MainScreen() + MainScreen( + onNavigateToQuestionDetail = { questionId -> + navController.navigate(NavRoutes.QuestionDetail.createRoute(questionId)) + }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/NavRoutes.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/NavRoutes.kt index 8efc6a9..ad3f952 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/NavRoutes.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/NavRoutes.kt @@ -1,7 +1,11 @@ package com.peto.droidmorning.navigation +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable sealed class NavRoutes( - val route: String, + @Transient val route: String = "", ) { data object LoginGraph : NavRoutes("login_graph") @@ -16,4 +20,17 @@ sealed class NavRoutes( data object History : NavRoutes("history") data object Profile : NavRoutes("profile") + + data object QuestionDetailGraph : NavRoutes("question_detail_graph") + + @Serializable + data class QuestionDetail( + val questionId: Long, + ) : NavRoutes(route = ROUTE) { + companion object { + const val ROUTE: String = "question_detail/{questionId}" + + fun createRoute(questionId: Long): String = "question_detail/$questionId" + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt new file mode 100644 index 0000000..170df92 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt @@ -0,0 +1,38 @@ +package com.peto.droidmorning.questions.detail.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navigation +import androidx.navigation.toRoute +import com.peto.droidmorning.navigation.NavGraphContributor +import com.peto.droidmorning.navigation.NavRoutes +import com.peto.droidmorning.questions.detail.QuestionDetailScreen + +class QuestionDetailNavGraph : NavGraphContributor { + override val graphRoute: NavRoutes + get() = NavRoutes.QuestionDetailGraph + override val startDestination: String + get() = NavRoutes.QuestionDetail.ROUTE + + override fun NavGraphBuilder.registerGraph(navController: NavHostController) { + navigation( + startDestination = startDestination, + route = graphRoute.route, + ) { + composable( + route = NavRoutes.QuestionDetail.ROUTE, + arguments = listOf(navArgument("questionId") { type = NavType.LongType }), + ) { backStackEntry -> + val args = backStackEntry.toRoute() + val questionId = args.questionId + QuestionDetailScreen( + questionId = questionId, + onNavigateBack = { navController.popBackStack() }, + ) + } + } + } +} From dc7c2957965df2b8a86f18c105ebd00ccea1430e Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:23:27 +0900 Subject: [PATCH 19/29] =?UTF-8?q?feat:=20QuestionDetail=20DI=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Koin 의존성 주입 모듈 설정 - ViewModelModule: QuestionDetailViewModel 등록 - NavigationModule: QuestionDetailNavGraph 등록 - parameterViewModelOf 사용한 파라미터 주입 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../kotlin/com/peto/droidmorning/di/NavigationModule.kt | 2 ++ .../kotlin/com/peto/droidmorning/di/ViewModelModule.kt | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/NavigationModule.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/NavigationModule.kt index eec71ca..cdf1c17 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/NavigationModule.kt @@ -3,6 +3,7 @@ package com.peto.droidmorning.di import com.peto.droidmorning.login.navigation.LoginNavGraphContributor import com.peto.droidmorning.main.navigation.MainNavGraphContributor import com.peto.droidmorning.navigation.NavGraphContributor +import com.peto.droidmorning.questions.detail.navigation.QuestionDetailNavGraph import org.koin.core.qualifier.named import org.koin.dsl.module @@ -10,4 +11,5 @@ val navigationModule = module { single(named("login")) { LoginNavGraphContributor() } single(named("main")) { MainNavGraphContributor() } + single(named("QuestionDetail")) { QuestionDetailNavGraph() } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/ViewModelModule.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/ViewModelModule.kt index c2f75ad..fddc37b 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/ViewModelModule.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/ViewModelModule.kt @@ -1,7 +1,8 @@ package com.peto.droidmorning.di import com.peto.droidmorning.login.vm.LoginViewModel -import com.peto.droidmorning.question.vm.QuestionViewModel +import com.peto.droidmorning.questions.detail.vm.QuestionDetailViewModel +import com.peto.droidmorning.questions.list.vm.QuestionViewModel import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module @@ -9,4 +10,5 @@ val viewModelModule = module { viewModelOf(::LoginViewModel) viewModelOf(::QuestionViewModel) + viewModelOf(::QuestionDetailViewModel) } From 3a3886af42f07751e73fba01dee66c1e6556181c Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:23:36 +0900 Subject: [PATCH 20/29] =?UTF-8?q?feat:=20QuestionDetail=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 상세 화면에 필요한 문자열 리소스 - 답변 관련 문자열 (내 답변, 답변 없음, 플레이스홀더) - 버튼 텍스트 (저장, 취소, 수정, 삭제) - 다이얼로그 메시지 - 답변 히스토리 텍스트 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../composeResources/values/strings.xml | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f468cb0..2703192 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -23,4 +23,43 @@ Android Compose OOP + + + 문제 상세 + 뒤로가기 + 즐겨찾기 + 답변 완료 + + + 내 답변 + 수정 + 삭제 + 취소 + 저장 + 마지막 수정: %s + 아직 작성한 답변이 없습니다 + 답변을 입력하세요 + + + 답변 삭제 + 정말 이 답변을 삭제하시겠습니까? + 정말 이 답변을 삭제하시겠습니까? + 이 답변을 삭제하시겠습니까? + + + 답변 히스토리 + + + 답변 추가하기 + + + 답변 작성 + 면접에서 이 질문을 받았을 때 어떻게 답변할지 적어보세요 + + + %s.%s.%s + 마지막 수정: %s + . + . + 마지막 수정: From 0b8e67f3873a734e5d99abde1f7f0d8dab909a17 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:23:46 +0900 Subject: [PATCH 21/29] =?UTF-8?q?feat:=20QuestionList=EC=99=80=20QuestionD?= =?UTF-8?q?etail=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제 목록에서 상세 화면으로 이동 구현 - QuestionList: 아이템 클릭 시 상세 화면 이동 - QuestionViewModel: NavigateToDetail 이벤트 추가 - QuestionUiEvent.NavigateToDetail 정의 - MainScreen: 이벤트 핸들러 연결 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../com/peto/droidmorning/main/MainScreen.kt | 8 +++++--- .../questions/list/QuestionScreen.kt | 16 +++++++--------- .../questions/list/model/QuestionUiEvent.kt | 2 -- .../questions/list/vm/QuestionViewModel.kt | 8 +++----- 4 files changed, 15 insertions(+), 19 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt index ec2604e..7ee4173 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt @@ -22,12 +22,12 @@ import androidx.compose.ui.tooling.preview.Preview import com.peto.droidmorning.designsystem.theme.AppTheme import com.peto.droidmorning.history.HistoryScreen import com.peto.droidmorning.profile.ProfileScreen -import com.peto.droidmorning.question.QuestionScreen +import com.peto.droidmorning.questions.list.QuestionScreen import com.peto.droidmorning.test.TestScreen import org.jetbrains.compose.resources.stringResource @Composable -fun MainScreen() { +fun MainScreen(onNavigateToQuestionDetail: (Long) -> Unit = {}) { var selectedTab by remember { mutableStateOf(BottomNavigationType.QUESTION) } Scaffold( @@ -41,6 +41,7 @@ fun MainScreen() { ) { paddingValues -> MainContent( selectedTab = selectedTab, + onNavigateToQuestionDetail = onNavigateToQuestionDetail, modifier = Modifier.padding(paddingValues), ) } @@ -89,6 +90,7 @@ private fun BottomNavigationBar( @Composable private fun MainContent( selectedTab: BottomNavigationType, + onNavigateToQuestionDetail: (Long) -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -97,7 +99,7 @@ private fun MainContent( when (selectedTab) { BottomNavigationType.QUESTION -> QuestionScreen( - onNavigateToDetail = {}, + onNavigateToDetail = onNavigateToQuestionDetail, ) BottomNavigationType.TEST -> TestScreen() BottomNavigationType.HISTORY -> HistoryScreen() diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt index 90287bd..cb8e834 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt @@ -23,13 +23,13 @@ import com.peto.droidmorning.common.ObserveAsEvents import com.peto.droidmorning.designsystem.component.AppSearchBar import com.peto.droidmorning.designsystem.theme.Dimen import com.peto.droidmorning.domain.model.Category -import com.peto.droidmorning.questions.component.CategoryChips -import com.peto.droidmorning.questions.component.EmptyQuestion -import com.peto.droidmorning.questions.component.QuestionFilterChips -import com.peto.droidmorning.questions.component.QuestionList -import com.peto.droidmorning.questions.vm.QuestionUiEvent -import com.peto.droidmorning.questions.vm.QuestionUiState -import com.peto.droidmorning.questions.vm.QuestionViewModel +import com.peto.droidmorning.questions.list.component.CategoryChips +import com.peto.droidmorning.questions.list.component.EmptyQuestion +import com.peto.droidmorning.questions.list.component.QuestionFilterChips +import com.peto.droidmorning.questions.list.component.QuestionList +import com.peto.droidmorning.questions.list.model.QuestionUiEvent +import com.peto.droidmorning.questions.list.model.QuestionUiState +import com.peto.droidmorning.questions.list.vm.QuestionViewModel import droidmorning.composeapp.generated.resources.Res import droidmorning.composeapp.generated.resources.question_empty_search import droidmorning.composeapp.generated.resources.question_empty_state @@ -62,8 +62,6 @@ fun QuestionScreen( is QuestionUiEvent.NavigateToQuestionDetail -> { onNavigateToDetail(event.questionId) } - is QuestionUiEvent.ShowError -> { - } is QuestionUiEvent.ScrollToTop -> { coroutineScope.launch { listState.scrollToItem(0) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiEvent.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiEvent.kt index a6d64f0..49bbed1 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiEvent.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiEvent.kt @@ -5,7 +5,5 @@ sealed interface QuestionUiEvent { val questionId: Long, ) : QuestionUiEvent - data object ShowError : QuestionUiEvent - data object ScrollToTop : QuestionUiEvent } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt index be620de..00e3629 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.peto.droidmorning.domain.model.Category import com.peto.droidmorning.domain.repository.QuestionRepository +import com.peto.droidmorning.questions.list.model.QuestionUiEvent +import com.peto.droidmorning.questions.list.model.QuestionUiState import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay @@ -92,10 +94,7 @@ class QuestionViewModel( questionRepository .toggleQuestionLike(questionId, isCurrentlyLiked) .onFailure { - _uiState.update { state -> - state.toggleQuestionLike(questionId) - } - sendUiEvent(QuestionUiEvent.ShowError) + _uiState.update { state -> state.toggleQuestionLike(questionId) } } } } @@ -109,7 +108,6 @@ class QuestionViewModel( _uiState.update { it.updateQuestions(questions) } }.onFailure { _uiState.update { it.loading(false) } - sendUiEvent(QuestionUiEvent.ShowError) } } } From fcf7975358d9d4d3fd2122277b74b095eb765bf3 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:23:56 +0900 Subject: [PATCH 22/29] =?UTF-8?q?test:=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 패키지 구조 변경으로 인한 테스트 코드 수정 - FakeRemoteQuestionDataSource: response 패키지 경로 수정 - QuestionResponseFixture: response 패키지 경로 수정 - UI 테스트 문서 추가 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../question/remote/DefaultRemoteQuestionDataSource.kt | 4 ++-- .../datasource/question/remote/RemoteQuestionDataSource.kt | 2 +- .../droidmorning/data/fake/FakeRemoteQuestionDataSource.kt | 2 +- .../peto/droidmorning/data/fixture/QuestionResponseFixture.kt | 4 +++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/DefaultRemoteQuestionDataSource.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/DefaultRemoteQuestionDataSource.kt index 9af92c0..dc0be1a 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/DefaultRemoteQuestionDataSource.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/DefaultRemoteQuestionDataSource.kt @@ -1,7 +1,7 @@ package com.peto.droidmorning.data.datasource.question.remote -import com.peto.droidmorning.data.model.LikeRequest -import com.peto.droidmorning.data.model.QuestionResponse +import com.peto.droidmorning.data.model.request.LikeRequest +import com.peto.droidmorning.data.model.response.QuestionResponse import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.postgrest.Postgrest import io.github.jan.supabase.postgrest.rpc diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/RemoteQuestionDataSource.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/RemoteQuestionDataSource.kt index 7e52b8a..eacac68 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/RemoteQuestionDataSource.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/question/remote/RemoteQuestionDataSource.kt @@ -1,6 +1,6 @@ package com.peto.droidmorning.data.datasource.question.remote -import com.peto.droidmorning.data.model.QuestionResponse +import com.peto.droidmorning.data.model.response.QuestionResponse interface RemoteQuestionDataSource { suspend fun fetchQuestions(): List diff --git a/data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSource.kt b/data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSource.kt index 33df236..25994ce 100644 --- a/data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSource.kt +++ b/data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSource.kt @@ -1,7 +1,7 @@ package com.peto.droidmorning.data.fake import com.peto.droidmorning.data.datasource.question.remote.RemoteQuestionDataSource -import com.peto.droidmorning.data.model.QuestionResponse +import com.peto.droidmorning.data.model.response.QuestionResponse class FakeRemoteQuestionDataSource( private val questions: List, diff --git a/data/src/commonTest/kotlin/com/peto/droidmorning/data/fixture/QuestionResponseFixture.kt b/data/src/commonTest/kotlin/com/peto/droidmorning/data/fixture/QuestionResponseFixture.kt index e983199..99e157c 100644 --- a/data/src/commonTest/kotlin/com/peto/droidmorning/data/fixture/QuestionResponseFixture.kt +++ b/data/src/commonTest/kotlin/com/peto/droidmorning/data/fixture/QuestionResponseFixture.kt @@ -1,6 +1,6 @@ package com.peto.droidmorning.data.fixture -import com.peto.droidmorning.data.model.QuestionResponse +import com.peto.droidmorning.data.model.response.QuestionResponse import com.peto.droidmorning.domain.model.Category import kotlin.time.Instant @@ -20,6 +20,8 @@ object QuestionResponseFixture { sourceUrl = sourceUrl, createdAt = createdAt, updatedAt = updatedAt, + isLiked = true, + isSolved = true, ) fun questionResponseList(size: Int = 3): List = From ae024aaa3b18b14f57edfb2dd042d0cbaf874fd1 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:24:04 +0900 Subject: [PATCH 23/29] =?UTF-8?q?chore:=20=EB=B9=8C=EB=93=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QuestionDetail 기능 구현을 위한 빌드 설정 - kotlinx-datetime 의존성 추가 - Napier 로깅 라이브러리 추가 - Gradle 버전 카탈로그 업데이트 🎯 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- composeApp/build.gradle.kts | 9 +++------ gradle/libs.versions.toml | 2 ++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 50a2242..33162f0 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.multiplatform) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) kotlin("native.cocoapods") } @@ -61,8 +62,10 @@ kotlin { implementation(libs.bundles.koin) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.collections.immutable) implementation(libs.napier) + implementation(libs.kotlinx.datetime) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -113,9 +116,3 @@ android { dependencies { debugImplementation(libs.compose.ui.tooling) } - -// compose.resources { -// publicResClass = false -// nameOfResClass = "Res" -// packageOfResClass = "com.peto.droidmorning.composeapp.generated.resources" -// } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2cec5bd..e2b5a0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" kotlinx-serialization-json = "1.9.0" kotlinx-collections-immutable = "0.4.0" +kotlinx-datetime = "0.7.1" # AndroidX androidx-core-ktx = "1.17.0" @@ -82,6 +83,7 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } From 208087d12c0180a2c0b34269f7e96739845fcdae Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:31:37 +0900 Subject: [PATCH 24/29] =?UTF-8?q?feat:=20=EB=A1=9C=EB=94=A9=EB=90=9C=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=EC=9D=B4=20=EC=98=AC=EB=B0=94=EB=A5=B8=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=EC=97=90=20=EC=97=B0=EA=B2=B0=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../questions/detail/vm/QuestionDetailViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt index 2dfae6f..973d70f 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt @@ -31,8 +31,9 @@ class QuestionDetailViewModel( questionRepository .fetchQuestions() .onSuccess { questions -> - questions.toList().find { it.id == questionId }?.let { - _uiState.update { it.updateQuestion(it.question) } + val question = questions.toList().find { it.id == questionId } + if (question != null) { + _uiState.update { it.updateQuestion(question) } loadAnswers() } } From 1f1ca2eb79d54b4aa78b5c7ca13c4d43f3124afd Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Fri, 16 Jan 2026 18:40:07 +0900 Subject: [PATCH 25/29] =?UTF-8?q?chore:=20=EB=AC=B8=EC=A0=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=99=94=EB=A9=B4=20TopAppBar=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=ED=8B=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commonMain/composeResources/values/strings.xml | 1 - .../questions/detail/QuestionDetailScreen.kt | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2703192..bc78bd3 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -25,7 +25,6 @@ OOP - 문제 상세 뒤로가기 즐겨찾기 답변 완료 diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt index c7d8054..0732201 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt @@ -20,8 +20,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -47,7 +47,6 @@ import droidmorning.composeapp.generated.resources.Res import droidmorning.composeapp.generated.resources.add_answer import droidmorning.composeapp.generated.resources.back import droidmorning.composeapp.generated.resources.favorite -import droidmorning.composeapp.generated.resources.question_detail_title import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf @@ -97,7 +96,7 @@ private fun QuestionDetailScreenContent( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(Res.string.question_detail_title)) }, + title = {}, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( @@ -126,6 +125,10 @@ private fun QuestionDetailScreenContent( ) } }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), ) }, bottomBar = { From 8eef3414e6f218f92dc44ec2a38628405b0d4b59 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 18 Jan 2026 01:33:07 +0900 Subject: [PATCH 26/29] =?UTF-8?q?feat:=20=EB=AC=B8=EC=A0=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=99=94=EB=A9=B4=EA=B3=BC=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=99=94=EB=A9=B4=20=EA=B0=84=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../com/peto/droidmorning/main/MainScreen.kt | 10 +++++++- .../navigation/MainNavGraphContributor.kt | 3 ++- .../questions/detail/QuestionDetailScreen.kt | 15 ++++++++++-- .../questions/detail/model/AnswerUiModel.kt | 7 +----- .../detail/model/QuestionDetailUiEvent.kt | 7 ++++++ .../detail/model/QuestionUpdateResult.kt | 9 +++++++ .../navigation/QuestionDetailNavGraph.kt | 16 ++++++++++++- .../detail/vm/QuestionDetailViewModel.kt | 17 +++++++++++++ .../questions/list/QuestionScreen.kt | 24 +++++++++++++++++++ .../questions/list/model/QuestionUiState.kt | 19 +++++++++++++++ .../questions/list/vm/QuestionViewModel.kt | 10 ++++++++ 11 files changed, 126 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiEvent.kt create mode 100644 composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionUpdateResult.kt diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt index 7ee4173..7258d93 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.SavedStateHandle import com.peto.droidmorning.designsystem.theme.AppTheme import com.peto.droidmorning.history.HistoryScreen import com.peto.droidmorning.profile.ProfileScreen @@ -27,7 +28,10 @@ import com.peto.droidmorning.test.TestScreen import org.jetbrains.compose.resources.stringResource @Composable -fun MainScreen(onNavigateToQuestionDetail: (Long) -> Unit = {}) { +fun MainScreen( + onNavigateToQuestionDetail: (Long) -> Unit = {}, + savedStateHandle: SavedStateHandle? = null, +) { var selectedTab by remember { mutableStateOf(BottomNavigationType.QUESTION) } Scaffold( @@ -42,6 +46,7 @@ fun MainScreen(onNavigateToQuestionDetail: (Long) -> Unit = {}) { MainContent( selectedTab = selectedTab, onNavigateToQuestionDetail = onNavigateToQuestionDetail, + savedStateHandle = savedStateHandle, modifier = Modifier.padding(paddingValues), ) } @@ -91,6 +96,7 @@ private fun BottomNavigationBar( private fun MainContent( selectedTab: BottomNavigationType, onNavigateToQuestionDetail: (Long) -> Unit, + savedStateHandle: SavedStateHandle?, modifier: Modifier = Modifier, ) { Box( @@ -100,7 +106,9 @@ private fun MainContent( BottomNavigationType.QUESTION -> QuestionScreen( onNavigateToDetail = onNavigateToQuestionDetail, + savedStateHandle = savedStateHandle, ) + BottomNavigationType.TEST -> TestScreen() BottomNavigationType.HISTORY -> HistoryScreen() BottomNavigationType.PROFILE -> ProfileScreen() diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt index 14f8d13..a784290 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/navigation/MainNavGraphContributor.kt @@ -20,11 +20,12 @@ class MainNavGraphContributor : NavGraphContributor { route = graphRoute.route, startDestination = startDestination, ) { - composable(NavRoutes.Main.route) { + composable(NavRoutes.Main.route) { backStackEntry -> MainScreen( onNavigateToQuestionDetail = { questionId -> navController.navigate(NavRoutes.QuestionDetail.createRoute(questionId)) }, + savedStateHandle = backStackEntry.savedStateHandle, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt index 0732201..f15828a 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.peto.droidmorning.common.ObserveAsEvents import com.peto.droidmorning.designsystem.component.AppPrimaryButton import com.peto.droidmorning.designsystem.theme.AppTheme import com.peto.droidmorning.designsystem.theme.Dimen @@ -40,7 +41,9 @@ import com.peto.droidmorning.questions.detail.component.AnswerHistory import com.peto.droidmorning.questions.detail.component.MyAnswer import com.peto.droidmorning.questions.detail.component.QuestionInfo import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.model.QuestionDetailUiEvent import com.peto.droidmorning.questions.detail.model.QuestionDetailUiState +import com.peto.droidmorning.questions.detail.model.QuestionUpdateResult import com.peto.droidmorning.questions.detail.preview.QuestionDetailPreviewParameterProvider import com.peto.droidmorning.questions.detail.vm.QuestionDetailViewModel import droidmorning.composeapp.generated.resources.Res @@ -55,15 +58,23 @@ import org.koin.core.parameter.parametersOf @Composable fun QuestionDetailScreen( questionId: Long, - onNavigateBack: () -> Unit, + onNavigateBack: (QuestionUpdateResult) -> Unit, viewModel: QuestionDetailViewModel = koinViewModel { parametersOf(questionId) }, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var showAddAnswerSheet by remember { mutableStateOf(false) } + ObserveAsEvents(viewModel.uiEvent) { event -> + when (event) { + is QuestionDetailUiEvent.NavigateBack -> { + onNavigateBack(event.result) + } + } + } + QuestionDetailScreenContent( uiState = uiState, - onNavigateBack = onNavigateBack, + onNavigateBack = viewModel::onNavigateBack, onToggleFavorite = viewModel::onToggleFavorite, onShowAddAnswerSheet = { showAddAnswerSheet = true }, onUpdateAnswer = { answer, content -> viewModel.onUpdateAnswer(answer, content) }, diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt index fab4a37..75e0bd3 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt @@ -14,9 +14,7 @@ sealed class AnswerUiModel { override val content: String, override val createdDate: String, override val updatedDate: String, - ) : AnswerUiModel() { - val isEditable: Boolean = true - } + ) : AnswerUiModel() data class History( val id: Long, @@ -29,9 +27,6 @@ sealed class AnswerUiModel { } } -/** - * Domain Answer를 UI AnswerUiModel로 변환 - */ fun Answer.toUiModel(): AnswerUiModel = when (this) { is Answer.Current -> diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiEvent.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiEvent.kt new file mode 100644 index 0000000..bc2951e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionDetailUiEvent.kt @@ -0,0 +1,7 @@ +package com.peto.droidmorning.questions.detail.model + +sealed interface QuestionDetailUiEvent { + data class NavigateBack( + val result: QuestionUpdateResult, + ) : QuestionDetailUiEvent +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionUpdateResult.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionUpdateResult.kt new file mode 100644 index 0000000..d85f760 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/QuestionUpdateResult.kt @@ -0,0 +1,9 @@ +package com.peto.droidmorning.questions.detail.model + +import kotlinx.serialization.Serializable + +@Serializable +data class QuestionUpdateResult( + val isLiked: Boolean, + val isSolved: Boolean, +) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt index 170df92..99012e4 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt @@ -28,11 +28,25 @@ class QuestionDetailNavGraph : NavGraphContributor { ) { backStackEntry -> val args = backStackEntry.toRoute() val questionId = args.questionId + QuestionDetailScreen( questionId = questionId, - onNavigateBack = { navController.popBackStack() }, + onNavigateBack = { result -> + navController.previousBackStackEntry?.savedStateHandle?.apply { + set(KEY_QUESTION_ID, questionId) + set(KEY_IS_LIKED, result.isLiked) + set(KEY_IS_SOLVED, result.isSolved) + } + navController.popBackStack() + }, ) } } } + + companion object { + const val KEY_QUESTION_ID = "question_id" + const val KEY_IS_LIKED = "is_liked" + const val KEY_IS_SOLVED = "is_solved" + } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt index 973d70f..18f7c65 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt @@ -5,10 +5,14 @@ import androidx.lifecycle.viewModelScope import com.peto.droidmorning.domain.repository.AnswerRepository import com.peto.droidmorning.domain.repository.QuestionRepository import com.peto.droidmorning.questions.detail.model.AnswerUiModel +import com.peto.droidmorning.questions.detail.model.QuestionDetailUiEvent import com.peto.droidmorning.questions.detail.model.QuestionDetailUiState +import com.peto.droidmorning.questions.detail.model.QuestionUpdateResult import com.peto.droidmorning.questions.detail.model.toUiModel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -20,6 +24,9 @@ class QuestionDetailViewModel( private val _uiState = MutableStateFlow(QuestionDetailUiState.initial()) val uiState = _uiState.asStateFlow() + private val _uiEvent = Channel(Channel.BUFFERED) + val uiEvent = _uiEvent.receiveAsFlow() + init { loadQuestionDetail() } @@ -146,4 +153,14 @@ class QuestionDetailViewModel( } } } + + fun onNavigateBack() { + viewModelScope.launch { + val result = + _uiState.value.question.run { + QuestionUpdateResult(isLiked, isSolved) + } + _uiEvent.send(QuestionDetailUiEvent.NavigateBack(result)) + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt index cb8e834..d05335d 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt @@ -18,11 +18,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.peto.droidmorning.common.ObserveAsEvents import com.peto.droidmorning.designsystem.component.AppSearchBar import com.peto.droidmorning.designsystem.theme.Dimen import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.questions.detail.navigation.QuestionDetailNavGraph import com.peto.droidmorning.questions.list.component.CategoryChips import com.peto.droidmorning.questions.list.component.EmptyQuestion import com.peto.droidmorning.questions.list.component.QuestionFilterChips @@ -41,11 +43,33 @@ import org.koin.compose.viewmodel.koinViewModel fun QuestionScreen( viewModel: QuestionViewModel = koinViewModel(), onNavigateToDetail: (Long) -> Unit, + savedStateHandle: SavedStateHandle? = null, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + LaunchedEffect(savedStateHandle) { + savedStateHandle + ?.getStateFlow(QuestionDetailNavGraph.KEY_QUESTION_ID, -1L) + ?.collect { questionId -> + if (questionId != -1L) { + val isLiked = savedStateHandle.get(QuestionDetailNavGraph.KEY_IS_LIKED) ?: false + val isSolved = savedStateHandle.get(QuestionDetailNavGraph.KEY_IS_SOLVED) ?: false + + viewModel.updateQuestionFromDetail( + questionId = questionId, + isLiked = isLiked, + isSolved = isSolved, + ) + + savedStateHandle[QuestionDetailNavGraph.KEY_QUESTION_ID] = -1L + savedStateHandle.remove(QuestionDetailNavGraph.KEY_IS_LIKED) + savedStateHandle.remove(QuestionDetailNavGraph.KEY_IS_SOLVED) + } + } + } + LaunchedEffect( uiState.searchQuery, uiState.selectedCategories, diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt index 884e495..dea35c0 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt @@ -64,6 +64,25 @@ data class QuestionUiState( return copy(allQuestions = Questions(updatedList)) } + /** + * 특정 문제의 좋아요와 해결 상태를 업데이트 + */ + fun updateQuestion( + questionId: Long, + isLiked: Boolean, + isSolved: Boolean, + ): QuestionUiState { + val updatedList = + allQuestions.toList().map { question -> + if (question.id == questionId) { + question.copy(isLiked = isLiked, isSolved = isSolved) + } else { + question + } + } + return copy(allQuestions = Questions(updatedList)) + } + fun loading(isLoading: Boolean): QuestionUiState = copy(isLoading = isLoading) fun filtering(): QuestionUiState = copy(isFiltering = true) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt index 00e3629..1a87947 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt @@ -99,6 +99,16 @@ class QuestionViewModel( } } + fun updateQuestionFromDetail( + questionId: Long, + isLiked: Boolean, + isSolved: Boolean, + ) { + _uiState.update { + it.updateQuestion(questionId, isLiked, isSolved) + } + } + private fun loadQuestions() { viewModelScope.launch { _uiState.update { it.loading(true) } From 79183878a5bb6170d0c526c72fd1d5bdf4c4e88a Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 18 Jan 2026 02:38:04 +0900 Subject: [PATCH 27/29] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 중복 문자열 리소스 제거 (last_modified, last_modified_format, date_suffix_*) - answer_history_count 문자열 리소스에 플레이스홀더 추가 - kotlinx.datetime.Instant를 kotlin.time.Instant로 변경 - IconButton 터치 타겟 크기를 Material Design 가이드라인(48dp)에 맞게 개선 - AnswerHistory 중복 카운트 표시 제거 🤖 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../src/commonMain/composeResources/values/strings.xml | 7 +------ .../com/peto/droidmorning/common/util/DateFormatter.kt | 2 +- .../questions/detail/component/AnswerCard.kt | 10 ++-------- .../questions/detail/component/AnswerHistory.kt | 6 ------ 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index bc78bd3..1a49de9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -35,7 +35,6 @@ 삭제 취소 저장 - 마지막 수정: %s 아직 작성한 답변이 없습니다 답변을 입력하세요 @@ -46,7 +45,7 @@ 이 답변을 삭제하시겠습니까? - 답변 히스토리 + 답변 히스토리 (%d) 답변 추가하기 @@ -56,9 +55,5 @@ 면접에서 이 질문을 받았을 때 어떻게 답변할지 적어보세요 - %s.%s.%s - 마지막 수정: %s - . - . 마지막 수정: diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt index bee34e4..502e3cc 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/common/util/DateFormatter.kt @@ -1,9 +1,9 @@ package com.peto.droidmorning.common.util -import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant object DateFormatter { private const val DATE_SEPARATOR = "." diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt index b5ef75f..aff63d4 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt @@ -88,10 +88,7 @@ fun AnswerCard( if (onEdit != null || onDelete != null) { Row(horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXxs)) { onEdit?.let { - IconButton( - onClick = onEdit, - modifier = Modifier.size(Dimen.iconLg), - ) { + IconButton(onClick = onEdit) { Icon( imageVector = Icons.Outlined.Edit, contentDescription = stringResource(Res.string.edit), @@ -102,10 +99,7 @@ fun AnswerCard( } onDelete?.let { - IconButton( - onClick = onDelete, - modifier = Modifier.size(Dimen.iconLg), - ) { + IconButton(onClick = onDelete) { Icon( imageVector = Icons.Outlined.Delete, contentDescription = stringResource(Res.string.delete), diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt index 08661b4..9f0689e 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt @@ -68,12 +68,6 @@ fun AnswerHistory( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) - - Text( - text = "(${historyAnswers.size})", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) } Column( From 42e17f33836bf554f04e546d22929ef5fe07d128 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 18 Jan 2026 02:42:15 +0900 Subject: [PATCH 28/29] =?UTF-8?q?refactor:=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A6=AC=EB=B7=B0=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnswerHistory에 key 추가하여 아이템 상태 혼동 방지 - QuestionDetailViewModel에 onFailure 핸들러 추가 및 질문 미발견 시 로딩 상태 해제 - CurrentAnswerResponse에서 kotlinx.datetime.Instant를 kotlin.time.Instant로 변경 - domain 모듈에서 사용하지 않는 kotlinx.datetime 의존성 제거 🤖 Generated with [Firebender](https://firebender.com) Co-Authored-By: Firebender --- .../questions/detail/component/AnswerHistory.kt | 13 ++++++++----- .../questions/detail/vm/QuestionDetailViewModel.kt | 4 ++++ .../data/model/response/CurrentAnswerResponse.kt | 2 +- domain/build.gradle.kts | 1 - 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt index 9f0689e..f89c70c 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -77,11 +78,13 @@ fun AnswerHistory( .padding(start = Dimen.spacingBase), ) { historyAnswers.forEachIndexed { index, answer -> - HistoryItem( - answer = answer, - isLast = index == historyAnswers.size - 1, - onDeleteAnswer = onDeleteAnswer, - ) + key(answer.id) { + HistoryItem( + answer = answer, + isLast = index == historyAnswers.size - 1, + onDeleteAnswer = onDeleteAnswer, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt index 18f7c65..9e32b07 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt @@ -42,7 +42,11 @@ class QuestionDetailViewModel( if (question != null) { _uiState.update { it.updateQuestion(question) } loadAnswers() + } else { + _uiState.update { it.loading(false) } } + }.onFailure { + _uiState.update { it.loading(false) } } } } diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt index 69e12d3..5e7adba 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CurrentAnswerResponse.kt @@ -1,9 +1,9 @@ package com.peto.droidmorning.data.model.response import com.peto.droidmorning.domain.model.Answer -import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlin.time.Instant @Serializable data class CurrentAnswerResponse( diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 80871b0..01876cb 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -24,7 +24,6 @@ kotlin { sourceSets { commonMain.dependencies { - implementation(libs.kotlinx.datetime) } commonTest.dependencies { implementation(libs.kotlin.test) From 0b05d5d02954367f1f6fd1eb35f323081c1b76a5 Mon Sep 17 00:00:00 2001 From: chanho0908 Date: Sun, 18 Jan 2026 02:51:22 +0900 Subject: [PATCH 29/29] =?UTF-8?q?fix:=20CategoryOOP=20=EC=83=89=EC=83=81?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/peto/droidmorning/designsystem/theme/Color.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Color.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Color.kt index 35a87fe..9ff5c98 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Color.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/Color.kt @@ -77,4 +77,4 @@ val CategoryKotlin = Color(0xFF5319E7) val CategoryAndroid = Color(0xFF01BD56) val CategoryCompose = Color(0xFFD9B110) val CategoryCoroutine = Color(0xFF01C4C6) -val CategoryOOP = Color(0xFFDECB95) +val CategoryOOP = Color(0xFFD77701)