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" }