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/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index f468cb0..1a49de9 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -23,4 +23,37 @@
Android
Compose
OOP
+
+
+ 뒤로가기
+ 즐겨찾기
+ 답변 완료
+
+
+ 내 답변
+ 수정
+ 삭제
+ 취소
+ 저장
+ 아직 작성한 답변이 없습니다
+ 답변을 입력하세요
+
+
+ 답변 삭제
+ 정말 이 답변을 삭제하시겠습니까?
+ 정말 이 답변을 삭제하시겠습니까?
+ 이 답변을 삭제하시겠습니까?
+
+
+ 답변 히스토리 (%d)
+
+
+ 답변 추가하기
+
+
+ 답변 작성
+ 면접에서 이 질문을 받았을 때 어떻게 답변할지 적어보세요
+
+
+ 마지막 수정:
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..502e3cc
--- /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.TimeZone
+import kotlinx.datetime.number
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Instant
+
+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'))
+ }
+ }
+}
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)
}
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..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,15 +19,19 @@ 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
-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 = {},
+ savedStateHandle: SavedStateHandle? = null,
+) {
var selectedTab by remember { mutableStateOf(BottomNavigationType.QUESTION) }
Scaffold(
@@ -41,6 +45,8 @@ fun MainScreen() {
) { paddingValues ->
MainContent(
selectedTab = selectedTab,
+ onNavigateToQuestionDetail = onNavigateToQuestionDetail,
+ savedStateHandle = savedStateHandle,
modifier = Modifier.padding(paddingValues),
)
}
@@ -89,6 +95,8 @@ private fun BottomNavigationBar(
@Composable
private fun MainContent(
selectedTab: BottomNavigationType,
+ onNavigateToQuestionDetail: (Long) -> Unit,
+ savedStateHandle: SavedStateHandle?,
modifier: Modifier = Modifier,
) {
Box(
@@ -97,8 +105,10 @@ private fun MainContent(
when (selectedTab) {
BottomNavigationType.QUESTION ->
QuestionScreen(
- onNavigateToDetail = {},
+ 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 6dec92e..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,8 +20,13 @@ class MainNavGraphContributor : NavGraphContributor {
route = graphRoute.route,
startDestination = startDestination,
) {
- composable(NavRoutes.Main.route) {
- MainScreen()
+ 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/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/QuestionDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt
new file mode 100644
index 0000000..f15828a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/QuestionDetailScreen.kt
@@ -0,0 +1,230 @@
+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.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+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.common.ObserveAsEvents
+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.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
+import droidmorning.composeapp.generated.resources.add_answer
+import droidmorning.composeapp.generated.resources.back
+import droidmorning.composeapp.generated.resources.favorite
+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: (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 = viewModel::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 = {},
+ 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
+ },
+ )
+ }
+ },
+ colors =
+ TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ ),
+ )
+ },
+ 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 = {},
+ )
+ }
+}
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 = {},
+ )
+ }
+}
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..aff63d4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt
@@ -0,0 +1,131 @@
+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) {
+ Icon(
+ imageVector = Icons.Outlined.Edit,
+ contentDescription = stringResource(Res.string.edit),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(Dimen.iconSm),
+ )
+ }
+ }
+
+ onDelete?.let {
+ IconButton(onClick = onDelete) {
+ 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/AnswerHistory.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt
new file mode 100644
index 0000000..f89c70c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt
@@ -0,0 +1,179 @@
+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.key
+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,
+ )
+ }
+
+ Column(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = Dimen.spacingBase),
+ ) {
+ historyAnswers.forEachIndexed { index, answer ->
+ key(answer.id) {
+ 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 = {},
+ )
+ }
+}
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 = {},
+ )
+ }
+}
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()
+ }
+}
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 = {},
+ )
+ }
+}
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,
+ )
+ }
+}
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..75e0bd3
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/model/AnswerUiModel.kt
@@ -0,0 +1,47 @@
+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()
+
+ 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
+ }
+}
+
+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/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/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/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
new file mode 100644
index 0000000..99012e4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/navigation/QuestionDetailNavGraph.kt
@@ -0,0 +1,52 @@
+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 = { 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/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",
+ ),
+ )
+}
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,
+ ),
+ )
+}
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..9e32b07
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt
@@ -0,0 +1,170 @@
+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.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
+
+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()
+
+ private val _uiEvent = Channel(Channel.BUFFERED)
+ val uiEvent = _uiEvent.receiveAsFlow()
+
+ init {
+ loadQuestionDetail()
+ }
+
+ private fun loadQuestionDetail() {
+ viewModelScope.launch {
+ _uiState.update { it.loading(true) }
+
+ questionRepository
+ .fetchQuestions()
+ .onSuccess { questions ->
+ val question = questions.toList().find { it.id == questionId }
+ if (question != null) {
+ _uiState.update { it.updateQuestion(question) }
+ loadAnswers()
+ } else {
+ _uiState.update { it.loading(false) }
+ }
+ }.onFailure {
+ _uiState.update { it.loading(false) }
+ }
+ }
+ }
+
+ 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))
+ }
+ }
+ }
+
+ 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/question/QuestionScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt
similarity index 78%
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..d05335d 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
@@ -18,18 +18,20 @@ 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.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.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
+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
@@ -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,
@@ -62,8 +86,6 @@ fun QuestionScreen(
is QuestionUiEvent.NavigateToQuestionDetail -> {
onNavigateToDetail(event.questionId)
}
- is QuestionUiEvent.ShowError -> {
- }
is QuestionUiEvent.ScrollToTop -> {
coroutineScope.launch {
listState.scrollToItem(0)
@@ -80,7 +102,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 67%
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..49bbed1 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,11 +1,9 @@
-package com.peto.droidmorning.question.vm
+package com.peto.droidmorning.questions.list.model
sealed interface QuestionUiEvent {
data class NavigateToQuestionDetail(
val questionId: Long,
) : QuestionUiEvent
- data object ShowError : QuestionUiEvent
-
data object ScrollToTop : QuestionUiEvent
}
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 82%
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..dea35c0 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
@@ -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/question/vm/QuestionViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt
similarity index 86%
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..1a87947 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,9 +1,11 @@
-package com.peto.droidmorning.question.vm
+package com.peto.droidmorning.questions.list.vm
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,14 +94,21 @@ class QuestionViewModel(
questionRepository
.toggleQuestionLike(questionId, isCurrentlyLiked)
.onFailure {
- _uiState.update { state ->
- state.toggleQuestionLike(questionId)
- }
- sendUiEvent(QuestionUiEvent.ShowError)
+ _uiState.update { state -> state.toggleQuestionLike(questionId) }
}
}
}
+ 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) }
@@ -109,7 +118,6 @@ class QuestionViewModel(
_uiState.update { it.updateQuestions(questions) }
}.onFailure {
_uiState.update { it.loading(false) }
- sendUiEvent(QuestionUiEvent.ShowError)
}
}
}
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/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/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/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/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/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,
+)
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..5e7adba
--- /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.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlin.time.Instant
+
+@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),
+ )
+}
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(
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)
+ }
+}
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 =
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/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)
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
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
+}
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" }