diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 33162f0..f073ae6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -64,8 +64,8 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.collections.immutable) - implementation(libs.napier) implementation(libs.kotlinx.datetime) + implementation(libs.napier) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1a49de9..5ec1280 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -3,7 +3,6 @@ 문제 시험 기록 - 프로필 Google 로그인 버튼 @@ -32,8 +31,6 @@ 내 답변 수정 - 삭제 - 취소 저장 아직 작성한 답변이 없습니다 답변을 입력하세요 @@ -45,7 +42,7 @@ 이 답변을 삭제하시겠습니까? - 답변 히스토리 (%d) + 답변 히스토리 답변 추가하기 @@ -56,4 +53,43 @@ 마지막 수정: + + + 시험 생성 + 시험 기록 + + + 문제 개수 + 카테고리 선택 + 사용 가능한 문제 + 선택한 카테고리에서 문제가 출제됩니다 + 시험 시작하기 + 문제 시험 + + + + 아직 시험 기록이 없습니다 + 시험을 시작해서 기록을 남겨보세요 + 정답 + 삭제 + 시험 삭제 + 이 시험 기록을 삭제하시겠습니까? + 시험 기록을 삭제했습니다. + + + 여기에 답변을 작성하세요... + 답변을 작성해주세요 + 이전 + 다음 + 제출하기 + + + 시험 상세 + 시험 완료! + 답변을 작성하지 않았습니다 + 내 답변 + 문제 목록으로 + 문제 + 질문 + 내 답변 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 cdf1c17..c6da9d6 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/NavigationModule.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/NavigationModule.kt @@ -1,5 +1,7 @@ package com.peto.droidmorning.di +import com.peto.droidmorning.exam.complete.navigation.ExamCompleteNavGraphContributor +import com.peto.droidmorning.exam.progress.navigation.ExamProgressNavGraphContributor import com.peto.droidmorning.login.navigation.LoginNavGraphContributor import com.peto.droidmorning.main.navigation.MainNavGraphContributor import com.peto.droidmorning.navigation.NavGraphContributor @@ -12,4 +14,6 @@ val navigationModule = single(named("login")) { LoginNavGraphContributor() } single(named("main")) { MainNavGraphContributor() } single(named("QuestionDetail")) { QuestionDetailNavGraph() } + single(named("ExamProgress")) { ExamProgressNavGraphContributor() } + single(named("ExamComplete")) { ExamCompleteNavGraphContributor() } } 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 fddc37b..298e396 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/ViewModelModule.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/di/ViewModelModule.kt @@ -1,6 +1,11 @@ package com.peto.droidmorning.di +import com.peto.droidmorning.exam.complete.vm.ExamCompleteViewModel +import com.peto.droidmorning.exam.detail.vm.ExamDetailViewModel +import com.peto.droidmorning.exam.main.vm.ExamViewModel +import com.peto.droidmorning.exam.progress.vm.ExamProgressViewModel import com.peto.droidmorning.login.vm.LoginViewModel +import com.peto.droidmorning.main.vm.MainViewModel import com.peto.droidmorning.questions.detail.vm.QuestionDetailViewModel import com.peto.droidmorning.questions.list.vm.QuestionViewModel import org.koin.core.module.dsl.viewModelOf @@ -9,6 +14,11 @@ import org.koin.dsl.module val viewModelModule = module { viewModelOf(::LoginViewModel) + viewModelOf(::MainViewModel) viewModelOf(::QuestionViewModel) viewModelOf(::QuestionDetailViewModel) + viewModelOf(::ExamViewModel) + viewModelOf(::ExamProgressViewModel) + viewModelOf(::ExamDetailViewModel) + viewModelOf(::ExamCompleteViewModel) } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/ExamCompleteScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/ExamCompleteScreen.kt new file mode 100644 index 0000000..2b88ae3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/ExamCompleteScreen.kt @@ -0,0 +1,170 @@ +package com.peto.droidmorning.exam.complete + +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.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.peto.droidmorning.designsystem.component.ExamQuestionCard +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.exam.complete.model.ExamCompleteUiState +import com.peto.droidmorning.exam.complete.preview.ExamCompleteUiStatePreviewParameterProvider +import com.peto.droidmorning.exam.complete.vm.ExamCompleteViewModel +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_result_back_to_questions +import droidmorning.composeapp.generated.resources.exam_result_complete_title +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun ExamCompleteScreen( + examId: Long, + onNavigateToQuestions: () -> Unit, + viewModel: ExamCompleteViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(examId) { + viewModel.loadExamDetail(examId) + } + + ExamCompleteScreenContent( + uiState = uiState, + onNavigateToQuestions = onNavigateToQuestions, + ) +} + +@Composable +private fun ExamCompleteScreenContent( + uiState: ExamCompleteUiState, + onNavigateToQuestions: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = + modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + containerColor = MaterialTheme.colorScheme.background, + bottomBar = { + Box( + modifier = + Modifier + .fillMaxWidth() + .background(Color.Transparent), + ) { + Button( + onClick = onNavigateToQuestions, + modifier = + Modifier + .fillMaxWidth() + .padding(Dimen.spacingBase), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + ) { + Text( + text = stringResource(Res.string.exam_result_back_to_questions), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + }, + ) { paddingValues -> + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(Dimen.spacingBase), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingBase), + ) { + item { ExamCompleteHeader() } + + itemsIndexed(uiState.examDetails) { index, examDetail -> + ExamQuestionCard( + questionNumber = index + 1, + examDetail = examDetail, + ) + } + } + } +} + +@Composable +private fun ExamCompleteHeader(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Dimen.spacingSm), + ) { + Box( + modifier = + Modifier + .size(80.dp) + .background( + color = Color(0xFFB8E6D5), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = Color(0xFF34A853), + ) + } + + Text( + text = stringResource(Res.string.exam_result_complete_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } +} + +@Preview +@Composable +private fun ExamCompleteScreenPreview( + @PreviewParameter(ExamCompleteUiStatePreviewParameterProvider::class) + uiState: ExamCompleteUiState, +) { + AppTheme { + ExamCompleteScreenContent( + uiState = uiState, + onNavigateToQuestions = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/model/ExamCompleteUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/model/ExamCompleteUiState.kt new file mode 100644 index 0000000..a12b263 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/model/ExamCompleteUiState.kt @@ -0,0 +1,14 @@ +package com.peto.droidmorning.exam.complete.model + +import androidx.compose.runtime.Immutable +import com.peto.droidmorning.domain.model.exam.ExamDetail +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Immutable +data class ExamCompleteUiState( + val examDetails: ImmutableList = persistentListOf(), +) { + fun updateExamDetails(examDetails: List): ExamCompleteUiState = copy(examDetails = examDetails.toImmutableList()) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/navigation/ExamCompleteNavGraphContributor.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/navigation/ExamCompleteNavGraphContributor.kt new file mode 100644 index 0000000..6c6e19d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/navigation/ExamCompleteNavGraphContributor.kt @@ -0,0 +1,48 @@ +package com.peto.droidmorning.exam.complete.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.exam.complete.ExamCompleteScreen +import com.peto.droidmorning.navigation.NavGraphContributor +import com.peto.droidmorning.navigation.NavRoutes + +class ExamCompleteNavGraphContributor : NavGraphContributor { + override val graphRoute: NavRoutes + get() = NavRoutes.ExamCompleteGraph + + override val startDestination: String + get() = NavRoutes.ExamComplete.ROUTE + + override val priority: Int = 4 + + override fun NavGraphBuilder.registerGraph(navController: NavHostController) { + navigation( + route = graphRoute.route, + startDestination = startDestination, + ) { + composable( + route = NavRoutes.ExamComplete.ROUTE, + arguments = + listOf( + navArgument("examId") { + type = NavType.LongType + }, + ), + ) { backStackEntry -> + val args = backStackEntry.toRoute() + + ExamCompleteScreen( + examId = args.examId, + onNavigateToQuestions = { + navController.popBackStack(NavRoutes.Main.route, inclusive = false) + }, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/preview/ExamCompleteUiStatePreviewParameterProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/preview/ExamCompleteUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000..9c8326a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/preview/ExamCompleteUiStatePreviewParameterProvider.kt @@ -0,0 +1,54 @@ +package com.peto.droidmorning.exam.complete.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamDetail +import com.peto.droidmorning.exam.complete.model.ExamCompleteUiState +import kotlinx.collections.immutable.toImmutableList + +class ExamCompleteUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + ExamCompleteUiState( + examDetails = + listOf( + ExamDetail( + examItemId = 1L, + examId = 1L, + questionId = 1L, + userAnswer = "ViewModel은 UI 관련 데이터를 관리하는 클래스입니다.", + questionTitle = "Android에서 ViewModel의 역할은 무엇인가요?", + questionCategory = Category.Android, + questionSourceUrl = "https://example.com/question1", + ), + ExamDetail( + examItemId = 2L, + examId = 1L, + questionId = 2L, + userAnswer = "Coroutine은 비동기 프로그래밍을 위한 경량 스레드입니다.", + questionTitle = "Kotlin Coroutine은 무엇인가요?", + questionCategory = Category.Kotlin, + questionSourceUrl = "https://example.com/question2", + ), + ExamDetail( + examItemId = 3L, + examId = 1L, + questionId = 3L, + userAnswer = "Jetpack Compose는 선언형 UI 프레임워크입니다.", + questionTitle = "Jetpack Compose의 특징은?", + questionCategory = Category.Compose, + questionSourceUrl = "https://example.com/question3", + ), + ExamDetail( + examItemId = 4L, + examId = 1L, + questionId = 4L, + userAnswer = "몰라용", + questionTitle = "단일 책임 원칙에 대해 설명해주세요", + questionCategory = Category.OOP, + questionSourceUrl = "https://example.com/question4", + ), + ).toImmutableList(), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/vm/ExamCompleteViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/vm/ExamCompleteViewModel.kt new file mode 100644 index 0000000..01d02aa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/complete/vm/ExamCompleteViewModel.kt @@ -0,0 +1,30 @@ +package com.peto.droidmorning.exam.complete.vm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.peto.droidmorning.domain.repository.ExamRepository +import com.peto.droidmorning.exam.complete.model.ExamCompleteUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ExamCompleteViewModel( + private val examRepository: ExamRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ExamCompleteUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadExamDetail(examId: Long) { + viewModelScope.launch { + examRepository + .fetchExamDetail(examId) + .onSuccess { examDetails -> + _uiState.update { + it.updateExamDetails(examDetails) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/ExamDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/ExamDetailScreen.kt new file mode 100644 index 0000000..232f488 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/ExamDetailScreen.kt @@ -0,0 +1,158 @@ +package com.peto.droidmorning.exam.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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 androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.peto.droidmorning.designsystem.component.ExamQuestionCard +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.exam.detail.model.ExamDetailUiState +import com.peto.droidmorning.exam.detail.preview.ExamDetailUiStatePreviewParameterProvider +import com.peto.droidmorning.exam.detail.vm.ExamDetailViewModel +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.back +import droidmorning.composeapp.generated.resources.exam_question_count_format +import droidmorning.composeapp.generated.resources.exam_result_title +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExamDetailScreen( + examId: Long, + onNavigateBack: () -> Unit, + viewModel: ExamDetailViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(examId) { + viewModel.loadExamDetail(examId) + } + + ExamDetailScreenContent( + uiState = uiState, + onNavigateBack = onNavigateBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExamDetailScreenContent( + uiState: ExamDetailUiState, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.exam_result_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) + }, + ) { paddingValues -> + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(Dimen.spacingBase), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingBase), + ) { + item { + ExamDetailHeader( + questionCount = uiState.examQuestionCount, + ) + } + + itemsIndexed(uiState.examQuestions) { index, question -> + ExamQuestionCard( + questionNumber = index + 1, + examDetail = question, + ) + } + } + } +} + +@Composable +private fun ExamDetailHeader( + questionCount: Int, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Dimen.spacingSm), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = null, + modifier = Modifier.size(Dimen.iconSm), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + Text( + text = "$questionCount${stringResource(Res.string.exam_question_count_format)}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } +} + +@Preview +@Composable +private fun ExamDetailScreenContentPreview( + @PreviewParameter(ExamDetailUiStatePreviewParameterProvider::class) + uiState: ExamDetailUiState, +) { + AppTheme { + ExamDetailScreenContent( + uiState = uiState, + onNavigateBack = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/model/ExamDetailUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/model/ExamDetailUiState.kt new file mode 100644 index 0000000..bfdffd5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/model/ExamDetailUiState.kt @@ -0,0 +1,14 @@ +package com.peto.droidmorning.exam.detail.model + +import androidx.compose.runtime.Stable +import com.peto.droidmorning.domain.model.exam.ExamDetail + +@Stable +data class ExamDetailUiState( + val examQuestions: List = emptyList(), +) { + val examQuestionCount: Int + get() = examQuestions.size + + fun updateExamQuestions(questions: List): ExamDetailUiState = copy(examQuestions = questions) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/preview/ExamDetailUiStatePreviewParameterProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/preview/ExamDetailUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000..62a21b9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/preview/ExamDetailUiStatePreviewParameterProvider.kt @@ -0,0 +1,79 @@ +package com.peto.droidmorning.exam.detail.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamDetail +import com.peto.droidmorning.exam.detail.model.ExamDetailUiState + +class ExamDetailUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf( + ExamDetailUiState( + examQuestions = + listOf( + ExamDetail( + examItemId = 1L, + examId = 1L, + questionId = 1L, + userAnswer = "val은 불변(immutable) 변수이고, var는 가변(mutable) 변수입니다.", + questionTitle = "Kotlin의 val과 var의 차이점은 무엇인가요?", + questionCategory = Category.Kotlin, + questionSourceUrl = "https://kotlinlang.org/docs/basic-syntax.html", + ), + ), + ), + ExamDetailUiState( + examQuestions = + listOf( + ExamDetail( + examItemId = 1L, + examId = 1L, + questionId = 1L, + userAnswer = "val은 불변(immutable) 변수이고, var는 가변(mutable) 변수입니다.", + questionTitle = "Kotlin의 val과 var의 차이점은 무엇인가요?", + questionCategory = Category.Kotlin, + questionSourceUrl = "https://kotlinlang.org/docs/basic-syntax.html", + ), + ExamDetail( + examItemId = 2L, + examId = 1L, + questionId = 2L, + userAnswer = "Dispatchers.Main, Dispatchers.IO, Dispatchers.Default, Dispatchers.Unconfined가 있습니다.", + questionTitle = "Coroutine의 Dispatcher 종류에 대해 설명해주세요.", + questionCategory = Category.Coroutine, + questionSourceUrl = "https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html", + ), + ExamDetail( + examItemId = 3L, + examId = 1L, + questionId = 3L, + userAnswer = "Composable 함수는 UI를 선언적으로 구성하는 함수입니다.", + questionTitle = "Jetpack Compose의 Composable 함수에 대해 설명해주세요.", + questionCategory = Category.Compose, + questionSourceUrl = "https://developer.android.com/jetpack/compose", + ), + ExamDetail( + examItemId = 4L, + examId = 1L, + questionId = 4L, + userAnswer = "ViewModel은 UI 관련 데이터를 보관하고 관리하는 클래스입니다.", + questionTitle = "Android의 ViewModel에 대해 설명해주세요.", + questionCategory = Category.Android, + questionSourceUrl = "https://developer.android.com/topic/libraries/architecture/viewmodel", + ), + ExamDetail( + examItemId = 5L, + examId = 1L, + questionId = 5L, + userAnswer = "SOLID 원칙은 객체지향 프로그래밍의 5가지 설계 원칙입니다.", + questionTitle = "SOLID 원칙에 대해 설명해주세요.", + questionCategory = Category.OOP, + questionSourceUrl = "https://en.wikipedia.org/wiki/SOLID", + ), + ), + ), + ExamDetailUiState( + examQuestions = emptyList(), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/vm/ExamDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/vm/ExamDetailViewModel.kt new file mode 100644 index 0000000..995804c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/detail/vm/ExamDetailViewModel.kt @@ -0,0 +1,30 @@ +package com.peto.droidmorning.exam.detail.vm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.peto.droidmorning.domain.repository.ExamRepository +import com.peto.droidmorning.exam.detail.model.ExamDetailUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ExamDetailViewModel( + private val examRepository: ExamRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ExamDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadExamDetail(examId: Long) { + viewModelScope.launch { + examRepository + .fetchExamDetail(examId) + .onSuccess { examDetail -> + _uiState.update { + it.updateExamQuestions(examDetail) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/ExamScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/ExamScreen.kt new file mode 100644 index 0000000..555e515 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/ExamScreen.kt @@ -0,0 +1,166 @@ +package com.peto.droidmorning.exam.main + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.ConfirmDialog +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.main.component.ExamCreateTab +import com.peto.droidmorning.exam.main.component.ExamHistoryTab +import com.peto.droidmorning.exam.main.model.ExamTab +import com.peto.droidmorning.exam.main.model.ExamUiEvent +import com.peto.droidmorning.exam.main.model.ExamUiState +import com.peto.droidmorning.exam.main.vm.ExamViewModel +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_delete_confirm_message +import droidmorning.composeapp.generated.resources.exam_delete_confirm_title +import droidmorning.composeapp.generated.resources.exam_delete_success +import droidmorning.composeapp.generated.resources.exam_tab_create +import droidmorning.composeapp.generated.resources.exam_tab_history +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@Composable +fun ExamScreen( + onNavigateToExamProgress: (questionCount: Int, categories: List) -> Unit, + onNavigateToExamResult: (Long) -> Unit, + viewModel: ExamViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val deleteSuccessMessage = stringResource(Res.string.exam_delete_success) + + ObserveAsEvents(flow = viewModel.uiEvent) { event -> + when (event) { + is ExamUiEvent.NavigateToExamProgress -> { + onNavigateToExamProgress(event.questionCount, event.categories) + } + is ExamUiEvent.NavigateToExamResult -> { + onNavigateToExamResult(event.examId) + } + ExamUiEvent.ShowDeleteSuccessMessage -> { + coroutineScope.launch { + snackbarHostState.showSnackbar(deleteSuccessMessage) + } + } + } + } + + ExamScreenContent( + uiState = uiState, + snackbarHostState = snackbarHostState, + onSelectTab = viewModel::selectTab, + onSelectQuestionCount = viewModel::selectQuestionCount, + onToggleCategory = viewModel::toggleCategory, + onStartExam = viewModel::startExam, + onOpenExamHistory = viewModel::openExamHistory, + onDeleteExam = viewModel::showDeleteConfirmation, + onConfirmDelete = viewModel::deleteExam, + onDismissDeleteDialog = viewModel::hideDeleteConfirmation, + ) +} + +@Composable +private fun ExamScreenContent( + uiState: ExamUiState, + snackbarHostState: SnackbarHostState, + onSelectTab: (ExamTab) -> Unit, + onSelectQuestionCount: (Int) -> Unit, + onToggleCategory: (Category) -> Unit, + onStartExam: () -> Unit, + onOpenExamHistory: (Long) -> Unit, + onDeleteExam: (Long) -> Unit, + onConfirmDelete: () -> Unit, + onDismissDeleteDialog: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { paddingValues -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + TabRow(selectedTabIndex = uiState.selectedTab.ordinal) { + Tab( + selected = uiState.selectedTab == ExamTab.CREATE, + onClick = { onSelectTab(ExamTab.CREATE) }, + text = { Text(stringResource(Res.string.exam_tab_create)) }, + ) + Tab( + selected = uiState.selectedTab == ExamTab.HISTORY, + onClick = { onSelectTab(ExamTab.HISTORY) }, + text = { Text(stringResource(Res.string.exam_tab_history)) }, + ) + } + + when (uiState.selectedTab) { + ExamTab.CREATE -> + ExamCreateTab( + state = uiState.createState, + onSelectQuestionCount = onSelectQuestionCount, + onToggleCategory = onToggleCategory, + onStartExam = onStartExam, + ) + ExamTab.HISTORY -> + ExamHistoryTab( + state = uiState.historyState, + onOpenExamHistory = onOpenExamHistory, + onDeleteExam = onDeleteExam, + ) + } + } + } + + if (uiState.examToDelete != null) { + ConfirmDialog( + onDismissRequest = onDismissDeleteDialog, + onConfirm = onConfirmDelete, + title = stringResource(Res.string.exam_delete_confirm_title), + message = stringResource(Res.string.exam_delete_confirm_message), + ) + } +} + +@Preview +@Composable +private fun ExamScreenPreview( + @PreviewParameter(com.peto.droidmorning.exam.main.preview.ExamUiStatePreviewProvider::class) + uiState: ExamUiState, +) { + AppTheme { + ExamScreenContent( + uiState = uiState, + snackbarHostState = remember { SnackbarHostState() }, + onSelectTab = {}, + onSelectQuestionCount = {}, + onToggleCategory = {}, + onStartExam = {}, + onOpenExamHistory = {}, + onDeleteExam = {}, + onConfirmDelete = {}, + onDismissDeleteDialog = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/AvailableQuestionInfo.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/AvailableQuestionInfo.kt new file mode 100644 index 0000000..7ebcdbd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/AvailableQuestionInfo.kt @@ -0,0 +1,93 @@ +package com.peto.droidmorning.exam.main.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.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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 androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_available_questions +import droidmorning.composeapp.generated.resources.exam_category_description +import droidmorning.composeapp.generated.resources.exam_count_unit +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AvailableQuestionInfo( + availableCount: Long, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + ).padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(Res.string.exam_available_questions), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(Res.string.exam_category_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + ) { + Text( + text = "$availableCount", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(Res.string.exam_count_unit), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@Preview +@Composable +private fun AvailableQuestionInfoPreview() { + AppTheme { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + AvailableQuestionInfo(availableCount = 0) + AvailableQuestionInfo(availableCount = 100) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/CategoryChip.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/CategoryChip.kt new file mode 100644 index 0000000..54ee17d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/CategoryChip.kt @@ -0,0 +1,78 @@ +package com.peto.droidmorning.exam.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.extendedColors +import com.peto.droidmorning.domain.model.category.Category + +@Composable +fun CategoryChip( + category: Category, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(RoundedCornerShape(12.dp)) + .background( + if (selected) { + MaterialTheme.extendedColors.examSelected + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ).clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = category.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = + if (selected) { + Color.White + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +@Preview +@Composable +private fun CategoryChipPreview() { + AppTheme { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CategoryChip( + category = Category.Kotlin, + selected = false, + onClick = {}, + modifier = Modifier.weight(1f), + ) + CategoryChip( + category = Category.Android, + selected = true, + onClick = {}, + modifier = Modifier.weight(1f), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/EmptyHistoryState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/EmptyHistoryState.kt new file mode 100644 index 0000000..6674719 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/EmptyHistoryState.kt @@ -0,0 +1,59 @@ +package com.peto.droidmorning.exam.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.filled.CheckCircle +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_history_empty_description +import droidmorning.composeapp.generated.resources.exam_history_empty_title +import org.jetbrains.compose.resources.stringResource + +@Composable +fun EmptyHistoryState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.outlineVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(Res.string.exam_history_empty_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.exam_history_empty_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Preview +@Composable +private fun EmptyHistoryStatePreview() { + AppTheme { + EmptyHistoryState() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamCreateTab.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamCreateTab.kt new file mode 100644 index 0000000..970d8ac --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamCreateTab.kt @@ -0,0 +1,192 @@ +package com.peto.droidmorning.exam.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.main.model.ExamCreateState +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_category_select +import droidmorning.composeapp.generated.resources.exam_question_count +import droidmorning.composeapp.generated.resources.exam_start +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ExamCreateTab( + state: ExamCreateState, + onSelectQuestionCount: (Int) -> Unit, + onToggleCategory: (Category) -> Unit, + onStartExam: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(Res.string.exam_question_count), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + listOf(5, 10, 15, 20).chunked(2).forEach { rowCounts -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowCounts.forEach { count -> + QuestionCountChip( + count = count, + selected = state.selectedQuestionCount == count, + onClick = { onSelectQuestionCount(count) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(Res.string.exam_category_select), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Category.entries.chunked(2).forEach { rowCategories -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowCategories.forEach { category -> + CategoryChip( + category = category, + selected = state.selectedCategories.contains(category), + onClick = { onToggleCategory(category) }, + modifier = Modifier.weight(1f), + ) + } + if (rowCategories.size == 1) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } + + AvailableQuestionInfo(availableCount = state.availableQuestionCount) + + Spacer(modifier = Modifier.weight(1f)) + + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background( + if (state.isValid) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ).clickable(enabled = state.isValid) { + onStartExam() + }.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = + if (state.isValid) { + Color.White + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(24.dp), + ) + Text( + text = stringResource(Res.string.exam_start), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = + if (state.isValid) { + Color.White + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } +} + +@Preview +@Composable +private fun ExamCreateTabPreview( + @PreviewParameter(com.peto.droidmorning.exam.main.preview.ExamCreateStatePreviewProvider::class) + state: ExamCreateState, +) { + AppTheme { + ExamCreateTab( + state = state, + onSelectQuestionCount = {}, + onToggleCategory = {}, + onStartExam = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamHistoryCard.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamHistoryCard.kt new file mode 100644 index 0000000..c6527ff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamHistoryCard.kt @@ -0,0 +1,145 @@ +package com.peto.droidmorning.exam.main.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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 androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +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.exam.main.model.ExamHistoryUiModel +import com.peto.droidmorning.exam.main.preview.ExamHistoryUiModelPreviewProvider +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_delete +import droidmorning.composeapp.generated.resources.exam_question_unit +import org.jetbrains.compose.resources.stringResource + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ExamHistoryCard( + uiModel: ExamHistoryUiModel, + onClick: () -> Unit, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = + CardDefaults.cardElevation( + defaultElevation = 2.dp, + ), + ) { + Column( + modifier = + Modifier + .clickable(onClick = onClick) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Description, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + modifier = Modifier.weight(1f), + ) { + Text( + text = "${uiModel.exampleCount}${stringResource(Res.string.exam_question_unit)}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.weight(1f)) + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + Text( + text = uiModel.formattedDate, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + } + IconButton( + onClick = onDeleteClick, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(Res.string.exam_delete), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(20.dp), + ) + } + } + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + uiModel.categories.forEach { category -> + CategoryBadge( + category = category, + categoryColor = category.color, + ) + } + } + } + } +} + +@Preview +@Composable +private fun ExamHistoryCardPreview( + @PreviewParameter(ExamHistoryUiModelPreviewProvider::class) + history: ExamHistoryUiModel, +) { + AppTheme { + ExamHistoryCard( + uiModel = history, + onClick = {}, + onDeleteClick = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamHistoryTab.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamHistoryTab.kt new file mode 100644 index 0000000..1339249 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/ExamHistoryTab.kt @@ -0,0 +1,67 @@ +package com.peto.droidmorning.exam.main.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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 androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.exam.main.model.ExamHistoryState +import com.peto.droidmorning.exam.main.preview.ExamHistoryStatePreviewProvider + +@Composable +fun ExamHistoryTab( + state: ExamHistoryState, + onOpenExamHistory: (Long) -> Unit, + onDeleteExam: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when { + state.histories.isEmpty() -> { + EmptyHistoryState() + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(16.dp), + ) { + items(state.histories) { history -> + ExamHistoryCard( + uiModel = history, + onClick = { onOpenExamHistory(history.id) }, + onDeleteClick = { onDeleteExam(history.id) }, + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun ExamHistoryTabPreview( + @PreviewParameter(ExamHistoryStatePreviewProvider::class) + state: ExamHistoryState, +) { + AppTheme { + ExamHistoryTab( + state = state, + onOpenExamHistory = {}, + onDeleteExam = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/QuestionCountChip.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/QuestionCountChip.kt new file mode 100644 index 0000000..7b45add --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/component/QuestionCountChip.kt @@ -0,0 +1,105 @@ +package com.peto.droidmorning.exam.main.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_question_unit +import org.jetbrains.compose.resources.stringResource + +@Composable +fun QuestionCountChip( + count: Int, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(RoundedCornerShape(16.dp)) + .background( + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surface + }, + ).border( + width = 1.dp, + color = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + shape = RoundedCornerShape(16.dp), + ).clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$count", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = + if (selected) { + Color.White + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = stringResource(Res.string.exam_question_unit), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = + if (selected) { + Color.White + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } +} + +@Preview +@Composable +private fun QuestionCountChipPreview() { + AppTheme { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + QuestionCountChip( + count = 5, + selected = false, + onClick = {}, + modifier = Modifier.weight(1f), + ) + QuestionCountChip( + count = 10, + selected = true, + onClick = {}, + modifier = Modifier.weight(1f), + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamCreateState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamCreateState.kt new file mode 100644 index 0000000..14b1db0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamCreateState.kt @@ -0,0 +1,37 @@ +package com.peto.droidmorning.exam.main.model + +import androidx.compose.runtime.Stable +import com.peto.droidmorning.domain.model.category.Category + +@Stable +data class ExamCreateState( + val selectedQuestionCount: Int = 5, + val selectedCategories: List = emptyList(), + val categoryCountMap: Map = emptyMap(), +) { + val availableQuestionCount: Long + get() = + selectedCategories.sumOf { category -> + categoryCountMap[category] ?: 0L + } + + val isValid: Boolean + get() = + selectedCategories.isNotEmpty() && + availableQuestionCount > 0 && + selectedQuestionCount <= availableQuestionCount + + fun selectQuestionCount(count: Int): ExamCreateState = copy(selectedQuestionCount = count) + + fun toggleCategory(category: Category): ExamCreateState { + val newCategories = + if (selectedCategories.contains(category)) { + selectedCategories - category + } else { + selectedCategories + category + } + return copy(selectedCategories = newCategories) + } + + fun updateCategoryCounts(countMap: Map): ExamCreateState = copy(categoryCountMap = countMap) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamHistoryState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamHistoryState.kt new file mode 100644 index 0000000..070d499 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamHistoryState.kt @@ -0,0 +1,10 @@ +package com.peto.droidmorning.exam.main.model + +import androidx.compose.runtime.Stable + +@Stable +data class ExamHistoryState( + val histories: List = emptyList(), +) { + fun updateHistories(histories: List): ExamHistoryState = copy(histories = histories) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamHistoryUiModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamHistoryUiModel.kt new file mode 100644 index 0000000..d31ad7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamHistoryUiModel.kt @@ -0,0 +1,22 @@ +package com.peto.droidmorning.exam.main.model + +import androidx.compose.runtime.Stable +import com.peto.droidmorning.common.util.DateFormatter +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamHistory + +@Stable +data class ExamHistoryUiModel( + val id: Long, + val exampleCount: Int, + val categories: List, + val formattedDate: String, +) + +fun ExamHistory.toUiModel(): ExamHistoryUiModel = + ExamHistoryUiModel( + id = id, + exampleCount = exampleCount, + categories = categories, + formattedDate = DateFormatter.formatDate(createdAt), + ) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamTab.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamTab.kt new file mode 100644 index 0000000..cd8ada4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamTab.kt @@ -0,0 +1,6 @@ +package com.peto.droidmorning.exam.main.model + +enum class ExamTab { + CREATE, + HISTORY, +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamUiEvent.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamUiEvent.kt new file mode 100644 index 0000000..1bce638 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamUiEvent.kt @@ -0,0 +1,16 @@ +package com.peto.droidmorning.exam.main.model + +import com.peto.droidmorning.domain.model.category.Category + +sealed interface ExamUiEvent { + data class NavigateToExamProgress( + val questionCount: Int, + val categories: List, + ) : ExamUiEvent + + data class NavigateToExamResult( + val examId: Long, + ) : ExamUiEvent + + data object ShowDeleteSuccessMessage : ExamUiEvent +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamUiState.kt new file mode 100644 index 0000000..5822289 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/model/ExamUiState.kt @@ -0,0 +1,28 @@ +package com.peto.droidmorning.exam.main.model + +import androidx.compose.runtime.Stable +import com.peto.droidmorning.domain.model.category.Category + +@Stable +data class ExamUiState( + val selectedTab: ExamTab = ExamTab.CREATE, + val createState: ExamCreateState = ExamCreateState(), + val historyState: ExamHistoryState = ExamHistoryState(), + val isLoading: Boolean = false, + val error: String? = null, + val examToDelete: Long? = null, +) { + fun selectTab(tab: ExamTab): ExamUiState = copy(selectedTab = tab) + + fun selectQuestionCount(count: Int): ExamUiState = copy(createState = createState.selectQuestionCount(count)) + + fun toggleCategory(category: Category): ExamUiState = copy(createState = createState.toggleCategory(category)) + + fun updateCategoryCounts(countMap: Map): ExamUiState = copy(createState = createState.updateCategoryCounts(countMap)) + + fun updateHistories(histories: List): ExamUiState = copy(historyState = historyState.updateHistories(histories)) + + fun showDeleteConfirmation(examId: Long): ExamUiState = copy(examToDelete = examId) + + fun hideDeleteConfirmation(): ExamUiState = copy(examToDelete = null) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamCreateStatePreviewProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamCreateStatePreviewProvider.kt new file mode 100644 index 0000000..e5a411b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamCreateStatePreviewProvider.kt @@ -0,0 +1,34 @@ +package com.peto.droidmorning.exam.main.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.main.model.ExamCreateState + +class ExamCreateStatePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = + sequenceOf( + ExamCreateState(), + ExamCreateState( + selectedQuestionCount = 10, + selectedCategories = listOf(Category.Kotlin, Category.Android), + categoryCountMap = + mapOf( + Category.Kotlin to 50L, + Category.Android to 30L, + Category.Compose to 20L, + Category.Coroutine to 15L, + Category.OOP to 10L, + ), + ), + ExamCreateState( + selectedQuestionCount = 5, + selectedCategories = listOf(Category.Kotlin), + categoryCountMap = + mapOf( + Category.Kotlin to 100L, + Category.Android to 50L, + ), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamHistoryPreviewProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamHistoryPreviewProvider.kt new file mode 100644 index 0000000..634099e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamHistoryPreviewProvider.kt @@ -0,0 +1,57 @@ +package com.peto.droidmorning.exam.main.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamHistory +import com.peto.droidmorning.exam.main.model.ExamHistoryUiModel +import kotlin.time.Instant + +class ExamHistoryPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = + sequenceOf( + ExamHistory( + id = 1L, + exampleCount = 10, + categories = listOf(Category.Kotlin, Category.Android), + createdAt = Instant.fromEpochMilliseconds(0), + ), + ExamHistory( + id = 2L, + exampleCount = 20, + categories = listOf(Category.Compose), + createdAt = Instant.fromEpochMilliseconds(0), + ), + ExamHistory( + id = 3L, + exampleCount = 15, + categories = listOf(Category.Kotlin, Category.Coroutine, Category.OOP), + createdAt = Instant.fromEpochMilliseconds(0), + ), + ) +} + +class ExamHistoryUiModelPreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = + sequenceOf( + ExamHistoryUiModel( + id = 1L, + exampleCount = 10, + categories = listOf(Category.Kotlin, Category.Android), + formattedDate = "2024년 1월 20일", + ), + ExamHistoryUiModel( + id = 2L, + exampleCount = 20, + categories = listOf(Category.Compose), + formattedDate = "2024년 1월 18일", + ), + ExamHistoryUiModel( + id = 3L, + exampleCount = 15, + categories = listOf(Category.Kotlin, Category.Coroutine, Category.OOP), + formattedDate = "2024년 1월 15일", + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamHistoryStatePreviewProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamHistoryStatePreviewProvider.kt new file mode 100644 index 0000000..5c1947c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamHistoryStatePreviewProvider.kt @@ -0,0 +1,37 @@ +package com.peto.droidmorning.exam.main.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.main.model.ExamHistoryState +import com.peto.droidmorning.exam.main.model.ExamHistoryUiModel + +class ExamHistoryStatePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = + sequenceOf( + ExamHistoryState(), + ExamHistoryState( + histories = + listOf( + ExamHistoryUiModel( + id = 1L, + exampleCount = 10, + categories = listOf(Category.Kotlin, Category.Android), + formattedDate = "2024년 1월 20일", + ), + ExamHistoryUiModel( + id = 2L, + exampleCount = 15, + categories = listOf(Category.Compose), + formattedDate = "2024년 1월 18일", + ), + ExamHistoryUiModel( + id = 3L, + exampleCount = 20, + categories = listOf(Category.Kotlin), + formattedDate = "2024년 1월 15일", + ), + ), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamUiStatePreviewProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamUiStatePreviewProvider.kt new file mode 100644 index 0000000..d373e2d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/preview/ExamUiStatePreviewProvider.kt @@ -0,0 +1,59 @@ +package com.peto.droidmorning.exam.main.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.main.model.ExamCreateState +import com.peto.droidmorning.exam.main.model.ExamHistoryState +import com.peto.droidmorning.exam.main.model.ExamHistoryUiModel +import com.peto.droidmorning.exam.main.model.ExamTab +import com.peto.droidmorning.exam.main.model.ExamUiState + +class ExamUiStatePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = + sequenceOf( + ExamUiState( + selectedTab = ExamTab.CREATE, + createState = + ExamCreateState( + selectedQuestionCount = 10, + selectedCategories = listOf(Category.Kotlin, Category.Android), + categoryCountMap = + mapOf( + Category.Kotlin to 50L, + Category.Android to 30L, + Category.Compose to 20L, + ), + ), + ), + ExamUiState( + selectedTab = ExamTab.CREATE, + createState = ExamCreateState(), + ), + ExamUiState( + selectedTab = ExamTab.HISTORY, + historyState = + ExamHistoryState( + histories = + listOf( + ExamHistoryUiModel( + id = 1L, + exampleCount = 10, + categories = listOf(Category.Kotlin), + formattedDate = "2024년 1월 20일", + ), + ExamHistoryUiModel( + id = 2L, + exampleCount = 15, + categories = listOf(Category.Android, Category.Compose), + formattedDate = "2024년 1월 18일", + ), + ), + ), + ), + ExamUiState( + selectedTab = ExamTab.HISTORY, + historyState = ExamHistoryState(), + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/vm/ExamViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/vm/ExamViewModel.kt new file mode 100644 index 0000000..78dae09 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/main/vm/ExamViewModel.kt @@ -0,0 +1,119 @@ +package com.peto.droidmorning.exam.main.vm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.repository.ExamRepository +import com.peto.droidmorning.domain.repository.QuestionRepository +import com.peto.droidmorning.exam.main.model.ExamTab +import com.peto.droidmorning.exam.main.model.ExamUiEvent +import com.peto.droidmorning.exam.main.model.ExamUiState +import com.peto.droidmorning.exam.main.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 ExamViewModel( + private val questionRepository: QuestionRepository, + private val examRepository: ExamRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ExamUiState()) + val uiState = _uiState.asStateFlow() + + private val _uiEvent = Channel(Channel.BUFFERED) + val uiEvent = _uiEvent.receiveAsFlow() + + init { + loadCategoryCounts() + loadExamHistory() + } + + fun selectTab(tab: ExamTab) { + _uiState.update { it.selectTab(tab) } + if (tab == ExamTab.HISTORY) { + loadExamHistory() + } + } + + fun selectQuestionCount(count: Int) { + _uiState.update { it.selectQuestionCount(count) } + } + + fun toggleCategory(category: Category) { + _uiState.update { it.toggleCategory(category) } + } + + private fun loadCategoryCounts() { + viewModelScope.launch { + questionRepository + .fetchAllCategoryCount() + .onSuccess { countMap -> + _uiState.update { examUiState -> + examUiState + .updateCategoryCounts(countMap) + } + } + } + } + + private fun loadExamHistory() { + viewModelScope.launch { + examRepository + .fetchExamHistory() + .onSuccess { histories -> + _uiState.update { examUiState -> + examUiState.updateHistories( + histories.map { it.toUiModel() }, + ) + } + } + } + } + + fun startExam() { + viewModelScope.launch { + val createState = _uiState.value.createState + + _uiEvent.send( + ExamUiEvent.NavigateToExamProgress( + questionCount = createState.selectedQuestionCount, + categories = createState.selectedCategories, + ), + ) + } + } + + fun openExamHistory(examId: Long) { + viewModelScope.launch { + _uiEvent.send(ExamUiEvent.NavigateToExamResult(examId)) + } + } + + fun showDeleteConfirmation(examId: Long) { + _uiState.update { it.showDeleteConfirmation(examId) } + } + + fun hideDeleteConfirmation() { + _uiState.update { it.hideDeleteConfirmation() } + } + + fun deleteExam() { + val examId = _uiState.value.examToDelete ?: return + viewModelScope.launch { + examRepository + .deleteExam(examId) + .onSuccess { + _uiState.update { it.hideDeleteConfirmation() } + _uiEvent.send(ExamUiEvent.ShowDeleteSuccessMessage) + loadExamHistory() + }.onFailure { + _uiState.update { + it.hideDeleteConfirmation() + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/ExamProgressScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/ExamProgressScreen.kt new file mode 100644 index 0000000..941979e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/ExamProgressScreen.kt @@ -0,0 +1,235 @@ +package com.peto.droidmorning.exam.progress + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +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.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.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.peto.droidmorning.common.ObserveAsEvents +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.progress.component.ExamAnswerInput +import com.peto.droidmorning.exam.progress.component.ExamNavigationButtons +import com.peto.droidmorning.exam.progress.component.ExamQuestionHeader +import com.peto.droidmorning.exam.progress.model.ExamProgressUiEvent +import com.peto.droidmorning.exam.progress.model.ExamProgressUiState +import com.peto.droidmorning.exam.progress.preview.ExamProgressUiStatePreviewProvider +import com.peto.droidmorning.exam.progress.vm.ExamProgressViewModel +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.back +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExamProgressScreen( + questionCount: Int, + categories: List, + onNavigateToComplete: (Long) -> Unit, + onNavigateBack: () -> Unit, + viewModel: ExamProgressViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.loadExamQuestions(questionCount, categories) + } + + ObserveAsEvents(flow = viewModel.uiEvent) { event -> + when (event) { + is ExamProgressUiEvent.NavigateToComplete -> + onNavigateToComplete(event.examId) + + ExamProgressUiEvent.NavigateBack -> onNavigateBack() + } + } + + ExamProgressScreenContent( + uiState = uiState, + onCancelExam = viewModel::cancelExam, + onAnswerChanged = viewModel::onAnswerChanged, + onPreviousQuestion = viewModel::previousQuestion, + onNextQuestion = viewModel::nextQuestion, + onSubmitExam = viewModel::submitExam, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExamProgressScreenContent( + uiState: ExamProgressUiState, + onCancelExam: () -> Unit, + onAnswerChanged: (Long, String) -> Unit, + onPreviousQuestion: () -> Unit, + onNextQuestion: () -> Unit, + onSubmitExam: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + + Scaffold( + modifier = + modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentWindowInsets = WindowInsets.systemBars, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onCancelExam) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(Res.string.back), + ) + } + }, + actions = { + Text( + text = + "${uiState.currentQuestionIndex + 1}/${uiState.questions.size}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(end = 16.dp), + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + scrollBehavior = scrollBehavior, + ) + }, + ) { paddingValues -> + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding(), + ) { + LinearProgressIndicator( + progress = { uiState.progress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + drawStopIndicator = {}, + ) + + uiState.currentQuestion?.let { currentQuestion -> + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + ExamQuestionHeader( + category = currentQuestion.category, + questionContent = currentQuestion.content, + modifier = Modifier.padding(16.dp), + ) + + HorizontalDivider() + + ExamAnswerInput( + answer = uiState.exams[currentQuestion.questionId], + onAnswerChange = { answer -> + onAnswerChanged(currentQuestion.questionId, answer) + }, + modifier = Modifier.padding(16.dp), + ) + } + + ExamNavigationButtons( + isFirstQuestion = uiState.isFirstQuestion, + isLastQuestion = uiState.isLastQuestion, + canGoNext = uiState.canGoNext, + canSubmit = uiState.canSubmit, + onPreviousClick = onPreviousQuestion, + onNextClick = onNextQuestion, + onSubmitClick = onSubmitExam, + ) + } + } + } + } +} + +@Preview +@Composable +private fun ExamProgressScreenWithAnswerPreview( + @PreviewParameter(ExamProgressUiStatePreviewProvider::class) uiState: ExamProgressUiState, +) { + AppTheme { + ExamProgressScreenContent( + uiState = uiState, + onCancelExam = {}, + onAnswerChanged = { _, _ -> }, + onPreviousQuestion = {}, + onNextQuestion = {}, + onSubmitExam = {}, + ) + } +} + +@Preview +@Composable +private fun ExamProgressScreenWithoutAnswerPreview() { + AppTheme { + ExamProgressScreenContent( + uiState = + ExamProgressUiState( + questions = + listOf( + com.peto.droidmorning.domain.model.exam.ExamQuestion( + questionId = 1L, + content = "Android에서 ViewModel의 역할은 무엇인가요?", + category = Category.Android, + ), + ), + currentQuestionIndex = 0, + ), + onCancelExam = {}, + onAnswerChanged = { _, _ -> }, + onPreviousQuestion = {}, + onNextQuestion = {}, + onSubmitExam = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamAnswerInput.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamAnswerInput.kt new file mode 100644 index 0000000..e996538 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamAnswerInput.kt @@ -0,0 +1,95 @@ +package com.peto.droidmorning.exam.progress.component + +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.ExamSelected +import com.peto.droidmorning.designsystem.theme.ExamUnSelected +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_answer_placeholder +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ExamAnswerInput( + answer: String, + onAnswerChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + val borderColor = + if (isFocused) { + ExamSelected + } else { + ExamUnSelected + } + + TextField( + value = answer, + onValueChange = onAnswerChange, + modifier = + modifier + .fillMaxWidth() + .height(300.dp) + .border( + width = 2.dp, + color = borderColor, + shape = RoundedCornerShape(24.dp), + ), + placeholder = { + Text( + text = stringResource(Res.string.exam_answer_placeholder), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + }, + shape = RoundedCornerShape(24.dp), + colors = + TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + unfocusedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + textStyle = MaterialTheme.typography.bodyLarge, + interactionSource = interactionSource, + ) +} + +@Preview +@Composable +private fun ExamAnswerInputPreview() { + AppTheme { + ExamAnswerInput( + answer = "data class는 자동으로 equals, hashCode, toString 메서드를 생성합니다.", + onAnswerChange = {}, + ) + } +} + +@Preview +@Composable +private fun ExamAnswerInputEmptyPreview() { + AppTheme { + ExamAnswerInput( + answer = "", + onAnswerChange = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamNavigationButtons.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamNavigationButtons.kt new file mode 100644 index 0000000..8068838 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamNavigationButtons.kt @@ -0,0 +1,244 @@ +package com.peto.droidmorning.exam.progress.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Send +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.peto.droidmorning.designsystem.theme.AppTheme +import droidmorning.composeapp.generated.resources.Res +import droidmorning.composeapp.generated.resources.exam_button_next +import droidmorning.composeapp.generated.resources.exam_button_previous +import droidmorning.composeapp.generated.resources.exam_button_submit +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ExamNavigationButtons( + isFirstQuestion: Boolean, + isLastQuestion: Boolean, + canGoNext: Boolean, + canSubmit: Boolean, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, + onSubmitClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(5.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + PreviousButton( + isFirstQuestion = isFirstQuestion, + onPreviousClick = onPreviousClick, + modifier = Modifier.weight(1f), + ) + + NextButton( + isLastQuestion = isLastQuestion, + canGoNext = canGoNext, + canSubmit = canSubmit, + onNextClick = onNextClick, + onSubmitClick = onSubmitClick, + modifier = Modifier.weight(2f), + ) + } +} + +@Composable +private fun PreviousButton( + isFirstQuestion: Boolean, + onPreviousClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(RoundedCornerShape(12.dp)) + .background( + if (isFirstQuestion) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + }, + ).border( + width = 1.dp, + color = MaterialTheme.colorScheme.outline, + shape = RoundedCornerShape(12.dp), + ).clickable(enabled = !isFirstQuestion) { + onPreviousClick() + }.padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = + if (isFirstQuestion) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + Text( + text = stringResource(Res.string.exam_button_previous), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = + if (isFirstQuestion) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } +} + +@Composable +private fun NextButton( + isLastQuestion: Boolean, + canGoNext: Boolean, + canSubmit: Boolean, + onNextClick: () -> Unit, + onSubmitClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isEnabled = if (isLastQuestion) canSubmit else canGoNext + + Box( + modifier = + modifier + .clip(RoundedCornerShape(12.dp)) + .background( + if (isEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ).clickable(enabled = isEnabled) { + if (isLastQuestion) { + onSubmitClick() + } else { + onNextClick() + } + }.padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + if (isLastQuestion) { + stringResource(Res.string.exam_button_submit) + } else { + stringResource(Res.string.exam_button_next) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (isEnabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = + if (isLastQuestion) { + Icons.Default.Send + } else { + Icons.AutoMirrored.Filled.ArrowForward + }, + contentDescription = null, + tint = if (isEnabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview +@Composable +private fun ExamNavigationButtonsFirstQuestionPreview() { + AppTheme { + ExamNavigationButtons( + isFirstQuestion = true, + isLastQuestion = false, + canGoNext = true, + canSubmit = true, + onPreviousClick = {}, + onNextClick = {}, + onSubmitClick = {}, + ) + } +} + +@Preview +@Composable +private fun ExamNavigationButtonsMiddleQuestionPreview() { + AppTheme { + ExamNavigationButtons( + isFirstQuestion = false, + isLastQuestion = false, + canGoNext = true, + canSubmit = true, + onPreviousClick = {}, + onNextClick = {}, + onSubmitClick = {}, + ) + } +} + +@Preview +@Composable +private fun ExamNavigationButtonsLastQuestionPreview() { + AppTheme { + ExamNavigationButtons( + isFirstQuestion = false, + isLastQuestion = true, + canGoNext = true, + canSubmit = true, + onPreviousClick = {}, + onNextClick = {}, + onSubmitClick = {}, + ) + } +} + +@Preview +@Composable +private fun ExamNavigationButtonsDisabledPreview() { + AppTheme { + ExamNavigationButtons( + isFirstQuestion = false, + isLastQuestion = false, + canGoNext = false, + canSubmit = false, + onPreviousClick = {}, + onNextClick = {}, + onSubmitClick = {}, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamQuestionHeader.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamQuestionHeader.kt new file mode 100644 index 0000000..2262e88 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/component/ExamQuestionHeader.kt @@ -0,0 +1,49 @@ +package com.peto.droidmorning.exam.progress.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.domain.model.category.Category + +@Composable +fun ExamQuestionHeader( + category: Category, + questionContent: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CategoryBadge( + category = category, + categoryColor = category.color, + ) + + Text( + text = questionContent, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } +} + +@Preview +@Composable +private fun ExamQuestionHeaderPreview() { + AppTheme { + ExamQuestionHeader( + category = Category.Kotlin, + questionContent = "Kotlin의 data class와 일반 class의 차이점은?", + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/model/ExamProgressUiEvent.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/model/ExamProgressUiEvent.kt new file mode 100644 index 0000000..44e6767 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/model/ExamProgressUiEvent.kt @@ -0,0 +1,9 @@ +package com.peto.droidmorning.exam.progress.model + +sealed interface ExamProgressUiEvent { + data class NavigateToComplete( + val examId: Long, + ) : ExamProgressUiEvent + + data object NavigateBack : ExamProgressUiEvent +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/model/ExamProgressUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/model/ExamProgressUiState.kt new file mode 100644 index 0000000..4a4dd98 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/model/ExamProgressUiState.kt @@ -0,0 +1,70 @@ +package com.peto.droidmorning.exam.progress.model + +import androidx.compose.runtime.Stable +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamQuestion +import com.peto.droidmorning.domain.model.exam.Exams + +@Stable +data class ExamProgressUiState( + val questions: List = emptyList(), + val categories: List = emptyList(), + val currentQuestionIndex: Int = 0, + val exams: Exams = Exams(), + val isLoading: Boolean = false, +) { + val currentQuestion: ExamQuestion? + get() = questions.getOrNull(currentQuestionIndex) + + val isFirstQuestion: Boolean + get() = currentQuestionIndex == 0 + + val isLastQuestion: Boolean + get() = currentQuestionIndex == questions.size - 1 + + val progress: Float + get() = if (questions.isEmpty()) 0f else (currentQuestionIndex + 1).toFloat() / questions.size + + val currentAnswer: String? + get() = currentQuestion?.let { exams[it.questionId] } + + val hasCurrentAnswer: Boolean + get() = !currentAnswer.isNullOrBlank() + + val canGoNext: Boolean + get() = hasCurrentAnswer + + val canSubmit: Boolean + get() { + if (questions.isEmpty()) return false + return questions.all { question -> + exams[question.questionId].isNotBlank() + } + } + + fun loading(isLoading: Boolean): ExamProgressUiState = copy(isLoading = isLoading) + + fun examLoaded( + examQuestions: List, + categories: List, + ): ExamProgressUiState = copy(questions = examQuestions, categories = categories, isLoading = false) + + fun updateAnswer( + questionId: Long, + answer: String, + ): ExamProgressUiState = copy(exams = exams.updateAnswer(questionId, answer)) + + fun moveToPreviousQuestion(): ExamProgressUiState = + if (currentQuestionIndex > 0) { + copy(currentQuestionIndex = currentQuestionIndex - 1) + } else { + this + } + + fun moveToNextQuestion(): ExamProgressUiState = + if (currentQuestionIndex < questions.size - 1) { + copy(currentQuestionIndex = currentQuestionIndex + 1) + } else { + this + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/navigation/ExamProgressNavGraphContributor.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/navigation/ExamProgressNavGraphContributor.kt new file mode 100644 index 0000000..796e68e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/navigation/ExamProgressNavGraphContributor.kt @@ -0,0 +1,52 @@ +package com.peto.droidmorning.exam.progress.navigation + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.compose.composable +import androidx.navigation.navigation +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.progress.ExamProgressScreen +import com.peto.droidmorning.navigation.NavGraphContributor +import com.peto.droidmorning.navigation.NavRoutes + +class ExamProgressNavGraphContributor : NavGraphContributor { + override val graphRoute: NavRoutes + get() = NavRoutes.ExamProgressGraph + + override val startDestination: String + get() = NavRoutes.ExamProgress.route + + override val priority: Int = 3 + + override fun NavGraphBuilder.registerGraph(navController: NavHostController) { + navigation( + route = graphRoute.route, + startDestination = startDestination, + ) { + composable(NavRoutes.ExamProgress.route) { backStackEntry -> + val questionCount = + navController.previousBackStackEntry + ?.savedStateHandle + ?.get("questionCount") ?: 5 + + val categoriesArray = + navController.previousBackStackEntry + ?.savedStateHandle + ?.get>("categories") ?: emptyArray() + + ExamProgressScreen( + questionCount = questionCount, + categories = categoriesArray.map { name -> Category.from(name) }, + onNavigateToComplete = { examId -> + navController.navigate(NavRoutes.ExamComplete.createRoute(examId.toLong())) { + popUpTo(NavRoutes.Main.route) + } + }, + onNavigateBack = { + navController.popBackStack() + }, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/preview/ExamProgressUiStatePreviewProvider.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/preview/ExamProgressUiStatePreviewProvider.kt new file mode 100644 index 0000000..bc5481a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/preview/ExamProgressUiStatePreviewProvider.kt @@ -0,0 +1,47 @@ +package com.peto.droidmorning.exam.progress.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamQuestion +import com.peto.droidmorning.domain.model.exam.Exams +import com.peto.droidmorning.exam.progress.model.ExamProgressUiState + +class ExamProgressUiStatePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = + sequenceOf( + ExamProgressUiState( + questions = + listOf( + ExamQuestion( + questionId = 1, + content = "Kotlin의 data class와 일반 class의 차이점은?", + category = Category.Kotlin, + ), + ExamQuestion( + questionId = 2, + content = "Coroutine의 Dispatcher 종류를 설명하시오", + category = Category.Coroutine, + ), + ExamQuestion( + questionId = 3, + content = "안드로이드 4대 컴포넌트에 대해 설명해주세요.", + category = Category.Android, + ), + ExamQuestion( + questionId = 4, + content = "선언형 UI란 무엇인가요 ?", + category = Category.Compose, + ), + ExamQuestion( + questionId = 5, + content = "단일 책임 원칙에 대해 설명해주세요", + category = Category.OOP, + ), + ), + currentQuestionIndex = 0, + exams = Exams(), + isLoading = false, + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/vm/ExamProgressViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/vm/ExamProgressViewModel.kt new file mode 100644 index 0000000..d41725f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/exam/progress/vm/ExamProgressViewModel.kt @@ -0,0 +1,85 @@ +package com.peto.droidmorning.exam.progress.vm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.repository.ExamRepository +import com.peto.droidmorning.domain.repository.QuestionRepository +import com.peto.droidmorning.exam.progress.model.ExamProgressUiEvent +import com.peto.droidmorning.exam.progress.model.ExamProgressUiState +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 ExamProgressViewModel( + private val questionRepository: QuestionRepository, + private val examRepository: ExamRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(ExamProgressUiState()) + val uiState = _uiState.asStateFlow() + + private val _uiEvent = Channel(Channel.BUFFERED) + val uiEvent = _uiEvent.receiveAsFlow() + + fun loadExamQuestions( + questionCount: Int, + categories: List, + ) { + viewModelScope.launch { + _uiState.update { it.loading(true) } + + questionRepository + .fetchExamQuestions( + questionCount = questionCount, + categories = categories, + ).onSuccess { examQuestions -> + _uiState.update { it.examLoaded(examQuestions, categories) } + }.onFailure { + _uiState.update { it.loading(false) } + } + } + } + + fun onAnswerChanged( + questionId: Long, + answer: String, + ) { + _uiState.update { it.updateAnswer(questionId, answer) } + } + + fun previousQuestion() { + _uiState.update { it.moveToPreviousQuestion() } + } + + fun nextQuestion() { + _uiState.update { it.moveToNextQuestion() } + } + + fun submitExam() { + viewModelScope.launch { + val state = _uiState.value + if (state.questions.isEmpty()) return@launch + + examRepository + .submitExam(state.exams, categories = state.categories) + .onSuccess { + sendEvent(ExamProgressUiEvent.NavigateToComplete(it)) + } + } + } + + fun cancelExam() { + viewModelScope.launch { + sendEvent(ExamProgressUiEvent.NavigateBack) + } + } + + private fun sendEvent(event: ExamProgressUiEvent) { + viewModelScope.launch { + _uiEvent.send(event) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/history/HistoryScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/history/HistoryScreen.kt deleted file mode 100644 index a6ace4f..0000000 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/history/HistoryScreen.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.peto.droidmorning.history - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable - -@Composable -fun HistoryScreen() { - Text("HistoryScreen") -} diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/BottomNavigationType.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/BottomNavigationType.kt index 57cd1ff..4206ae9 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/BottomNavigationType.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/BottomNavigationType.kt @@ -1,14 +1,10 @@ package com.peto.droidmorning.main import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.automirrored.outlined.MenuBook import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Person import androidx.compose.ui.graphics.vector.ImageVector import droidmorning.composeapp.generated.resources.Res -import droidmorning.composeapp.generated.resources.bottom_nav_history -import droidmorning.composeapp.generated.resources.bottom_nav_profile import droidmorning.composeapp.generated.resources.bottom_nav_question import droidmorning.composeapp.generated.resources.bottom_nav_test import org.jetbrains.compose.resources.StringResource @@ -21,16 +17,8 @@ enum class BottomNavigationType( icon = Icons.AutoMirrored.Outlined.MenuBook, label = Res.string.bottom_nav_question, ), - TEST( + EXAM( icon = Icons.Filled.Description, label = Res.string.bottom_nav_test, ), - HISTORY( - icon = Icons.AutoMirrored.Filled.ListAlt, - label = Res.string.bottom_nav_history, - ), - PROFILE( - icon = Icons.Filled.Person, - label = Res.string.bottom_nav_profile, - ), } 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 7258d93..73a77c5 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/MainScreen.kt @@ -14,38 +14,42 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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 androidx.lifecycle.compose.collectAsStateWithLifecycle import com.peto.droidmorning.designsystem.theme.AppTheme -import com.peto.droidmorning.history.HistoryScreen -import com.peto.droidmorning.profile.ProfileScreen +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.exam.main.ExamScreen +import com.peto.droidmorning.main.vm.MainViewModel import com.peto.droidmorning.questions.list.QuestionScreen -import com.peto.droidmorning.test.TestScreen import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel @Composable fun MainScreen( - onNavigateToQuestionDetail: (Long) -> Unit = {}, + onNavigateToQuestionDetail: (Long) -> Unit, + onNavigateToExamProgress: (questionCount: Int, categories: List) -> Unit, + onNavigateToExamResult: (Long) -> Unit, savedStateHandle: SavedStateHandle? = null, + viewModel: MainViewModel = koinViewModel(), ) { - var selectedTab by remember { mutableStateOf(BottomNavigationType.QUESTION) } + val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() Scaffold( modifier = Modifier.fillMaxSize(), bottomBar = { BottomNavigationBar( selectedTab = selectedTab, - onTabSelected = { selectedTab = it }, + onTabSelected = viewModel::selectTab, ) }, ) { paddingValues -> MainContent( selectedTab = selectedTab, onNavigateToQuestionDetail = onNavigateToQuestionDetail, + onNavigateToExamProgress = onNavigateToExamProgress, + onNavigateToExamResult = onNavigateToExamResult, savedStateHandle = savedStateHandle, modifier = Modifier.padding(paddingValues), ) @@ -96,6 +100,8 @@ private fun BottomNavigationBar( private fun MainContent( selectedTab: BottomNavigationType, onNavigateToQuestionDetail: (Long) -> Unit, + onNavigateToExamProgress: (questionCount: Int, categories: List) -> Unit, + onNavigateToExamResult: (Long) -> Unit, savedStateHandle: SavedStateHandle?, modifier: Modifier = Modifier, ) { @@ -109,9 +115,11 @@ private fun MainContent( savedStateHandle = savedStateHandle, ) - BottomNavigationType.TEST -> TestScreen() - BottomNavigationType.HISTORY -> HistoryScreen() - BottomNavigationType.PROFILE -> ProfileScreen() + BottomNavigationType.EXAM -> + ExamScreen( + onNavigateToExamProgress = onNavigateToExamProgress, + onNavigateToExamResult = onNavigateToExamResult, + ) } } } @@ -120,6 +128,10 @@ private fun MainContent( @Composable fun MainScreenPreview() { AppTheme { - MainScreen() + MainScreen( + onNavigateToQuestionDetail = {}, + onNavigateToExamProgress = { _, _ -> }, + onNavigateToExamResult = {}, + ) } } 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 a784290..94ade79 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 @@ -2,8 +2,12 @@ package com.peto.droidmorning.main.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.exam.detail.ExamDetailScreen import com.peto.droidmorning.main.MainScreen import com.peto.droidmorning.navigation.NavGraphContributor import com.peto.droidmorning.navigation.NavRoutes @@ -25,9 +29,37 @@ class MainNavGraphContributor : NavGraphContributor { onNavigateToQuestionDetail = { questionId -> navController.navigate(NavRoutes.QuestionDetail.createRoute(questionId)) }, + onNavigateToExamProgress = { questionCount, categories -> + navController.currentBackStackEntry?.savedStateHandle?.apply { + set("questionCount", questionCount) + set("categories", categories.map { it.name }.toTypedArray()) + } + navController.navigate(NavRoutes.ExamProgressGraph.route) + }, + onNavigateToExamResult = { examId -> + navController.navigate(NavRoutes.ExamDetail.createRoute(examId)) + }, savedStateHandle = backStackEntry.savedStateHandle, ) } + + composable( + route = NavRoutes.ExamDetail.ROUTE, + arguments = + listOf( + navArgument("examId") { + type = NavType.LongType + }, + ), + ) { backStackEntry -> + val args = backStackEntry.toRoute() + ExamDetailScreen( + examId = args.examId, + onNavigateBack = { + navController.popBackStack(NavRoutes.Main.route, inclusive = false) + }, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/vm/MainViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/vm/MainViewModel.kt new file mode 100644 index 0000000..a0235e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/main/vm/MainViewModel.kt @@ -0,0 +1,29 @@ +package com.peto.droidmorning.main.vm + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.peto.droidmorning.main.BottomNavigationType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class MainViewModel( + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val _selectedTab = + MutableStateFlow( + savedStateHandle + .get(SELECTED_TAB_KEY) + ?.let { BottomNavigationType.valueOf(it) } + ?: BottomNavigationType.QUESTION, + ) + val selectedTab = _selectedTab.asStateFlow() + + fun selectTab(tab: BottomNavigationType) { + _selectedTab.value = tab + savedStateHandle[SELECTED_TAB_KEY] = tab.name + } + + companion object { + private const val SELECTED_TAB_KEY = "selectedTab" + } +} 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 ad3f952..4c45ea4 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/NavRoutes.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/navigation/NavRoutes.kt @@ -15,12 +15,8 @@ sealed class NavRoutes( data object Main : NavRoutes("main") - data object Test : NavRoutes("test") - data object History : NavRoutes("history") - data object Profile : NavRoutes("profile") - data object QuestionDetailGraph : NavRoutes("question_detail_graph") @Serializable @@ -33,4 +29,32 @@ sealed class NavRoutes( fun createRoute(questionId: Long): String = "question_detail/$questionId" } } + + data object ExamProgressGraph : NavRoutes("exam_progress_graph") + + data object ExamProgress : NavRoutes("exam_progress") + + data object ExamCompleteGraph : NavRoutes("exam_complete_graph") + + @Serializable + data class ExamComplete( + val examId: Long, + ) : NavRoutes(route = ROUTE) { + companion object { + const val ROUTE: String = "exam_complete/{examId}" + + fun createRoute(examId: Long): String = "exam_complete/$examId" + } + } + + @Serializable + data class ExamDetail( + val examId: Long, + ) : NavRoutes(route = ROUTE) { + companion object Companion { + const val ROUTE: String = "exam_detail/{examId}" + + fun createRoute(examId: Long): String = "exam_detail/$examId" + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/profile/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/profile/ProfileScreen.kt deleted file mode 100644 index 991616b..0000000 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/profile/ProfileScreen.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.peto.droidmorning.profile - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable - -@Composable -fun ProfileScreen() { - Text("ProfileScreen") -} 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 index dd0bf4f..09e5d24 100644 --- 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 @@ -28,12 +28,13 @@ 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.generated.resources.DesignRes +import com.peto.droidmorning.designsystem.generated.resources.cancel 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 @@ -104,12 +105,12 @@ private fun AddAnswerBottomSheetContent( } }, ) { - Text(stringResource(Res.string.cancel)) + Text(stringResource(DesignRes.string.cancel)) } TextButton( onClick = { - if (draftAnswer.trim().isNotEmpty()) { + if (draftAnswer.isNotEmpty()) { scope.launch { onSave(draftAnswer) sheetState.hide() diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt index aff63d4..d753df0 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerCard.kt @@ -19,12 +19,13 @@ 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.generated.resources.DesignRes +import com.peto.droidmorning.designsystem.generated.resources.cancel 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 @@ -102,7 +103,7 @@ fun AnswerCard( IconButton(onClick = onDelete) { Icon( imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(Res.string.delete), + contentDescription = stringResource(DesignRes.string.cancel), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(Dimen.iconSm), ) diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt index f89c70c..520b68b 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/component/AnswerHistory.kt @@ -31,14 +31,15 @@ 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.generated.resources.DesignRes +import com.peto.droidmorning.designsystem.generated.resources.cancel +import com.peto.droidmorning.designsystem.generated.resources.remove 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 @@ -65,7 +66,7 @@ fun AnswerHistory( modifier = Modifier.size(Dimen.iconMd), ) Text( - text = stringResource(Res.string.answer_history_count, historyAnswers.size), + text = "${stringResource(Res.string.answer_history_count)}(${historyAnswers.size})", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) @@ -154,8 +155,8 @@ private fun HistoryItem( }, 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), + confirmText = stringResource(DesignRes.string.remove), + cancelText = stringResource(DesignRes.string.cancel), icon = Icons.Outlined.Delete, iconTint = MaterialTheme.colorScheme.error, iconBackgroundColor = MaterialTheme.colorScheme.errorContainer, 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 index 67ee32b..6566272 100644 --- 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 @@ -23,11 +23,12 @@ 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.generated.resources.DesignRes +import com.peto.droidmorning.designsystem.generated.resources.cancel 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 @@ -65,7 +66,7 @@ fun EditAnswerCard( ) { TextButton(onClick = onCancel) { Text( - stringResource(Res.string.cancel), + stringResource(DesignRes.string.cancel), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } 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 index 7a46eb7..4cde1c2 100644 --- 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 @@ -22,13 +22,14 @@ 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.generated.resources.DesignRes +import com.peto.droidmorning.designsystem.generated.resources.cancel +import com.peto.droidmorning.designsystem.generated.resources.remove 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 @@ -108,8 +109,8 @@ fun MyAnswer( }, 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), + confirmText = stringResource(DesignRes.string.remove), + cancelText = stringResource(DesignRes.string.cancel), icon = Icons.Outlined.Delete, iconTint = MaterialTheme.colorScheme.error, iconBackgroundColor = MaterialTheme.colorScheme.errorContainer, 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 index 15d4f22..f545450 100644 --- 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 @@ -21,7 +21,7 @@ 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 com.peto.droidmorning.domain.model.category.Category import droidmorning.composeapp.generated.resources.Res import droidmorning.composeapp.generated.resources.answer_completed import org.jetbrains.compose.resources.stringResource 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 index 05838b9..27ebd54 100644 --- 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 @@ -1,8 +1,8 @@ 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 com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.question.Question import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList 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 index d5e01c1..e049ffb 100644 --- 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 @@ -1,8 +1,8 @@ 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.domain.model.category.Category +import com.peto.droidmorning.domain.model.question.Question import com.peto.droidmorning.questions.detail.model.AnswerUiModel import com.peto.droidmorning.questions.detail.model.QuestionDetailUiState import kotlinx.collections.immutable.persistentListOf diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt index 9e32b07..cb230a5 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/detail/vm/QuestionDetailViewModel.kt @@ -113,18 +113,14 @@ class QuestionDetailViewModel( } is AnswerUiModel.Current -> { - // 현재 답변 삭제 answerRepository .deleteCurrentAnswer(questionId) .onSuccess { - // loadAnswers()가 완료될 때까지 기다린 후 상태 확인 viewModelScope.launch { loadAnswers() - // loadAnswers() 완료 후 히스토리에서 복원된 답변이 있는지 확인 val hasAnswerAfterDelete = _uiState.value.currentAnswer != null if (!hasAnswerAfterDelete) { - // 모든 답변이 삭제되었으면 미해결 상태로 변경 updateQuestionSolvedStatus(false) } } diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt index d05335d..34a332d 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/QuestionScreen.kt @@ -23,7 +23,7 @@ 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.domain.model.category.Category import com.peto.droidmorning.questions.detail.navigation.QuestionDetailNavGraph import com.peto.droidmorning.questions.list.component.CategoryChips import com.peto.droidmorning.questions.list.component.EmptyQuestion diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/CategoryChips.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/CategoryChips.kt index 3185d52..74558ff 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/CategoryChips.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/CategoryChips.kt @@ -11,7 +11,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.peto.droidmorning.designsystem.component.CategoryFilterChip import com.peto.droidmorning.designsystem.theme.AppTheme import com.peto.droidmorning.designsystem.theme.Dimen -import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.category.Category import droidmorning.composeapp.generated.resources.Res import droidmorning.composeapp.generated.resources.category_android import droidmorning.composeapp.generated.resources.category_compose diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionFilterChips.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionFilterChips.kt index d3f56f4..53ebb16 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionFilterChips.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionFilterChips.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.tooling.preview.Preview import com.peto.droidmorning.designsystem.component.CategoryFilterChip import com.peto.droidmorning.designsystem.theme.AppTheme import com.peto.droidmorning.designsystem.theme.Dimen -import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.category.Category import droidmorning.composeapp.generated.resources.Res import droidmorning.composeapp.generated.resources.question_filter_category import droidmorning.composeapp.generated.resources.question_filter_favorites diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionList.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionList.kt index edb3b72..185b6f1 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionList.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/component/QuestionList.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.tooling.preview.Preview import com.peto.droidmorning.designsystem.component.QuestionCard import com.peto.droidmorning.designsystem.theme.AppTheme import com.peto.droidmorning.designsystem.theme.Dimen -import com.peto.droidmorning.domain.model.Category -import com.peto.droidmorning.domain.model.Question +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.question.Question import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlin.time.Instant diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt index dea35c0..491c098 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/model/QuestionUiState.kt @@ -1,11 +1,11 @@ package com.peto.droidmorning.questions.list.model import androidx.compose.runtime.Stable -import com.peto.droidmorning.domain.model.Category import com.peto.droidmorning.domain.model.Filter -import com.peto.droidmorning.domain.model.Question -import com.peto.droidmorning.domain.model.Questions import com.peto.droidmorning.domain.model.SearchQuery +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.question.Question +import com.peto.droidmorning.domain.model.question.Questions import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableList @@ -16,7 +16,7 @@ data class QuestionUiState( private val allQuestions: Questions = Questions(emptyList()), val filter: Filter = Filter(), val showCategoryFilters: Boolean = false, - val isLoading: Boolean = false, + val isLoading: Boolean = true, val isFiltering: Boolean = false, ) { val searchQuery: SearchQuery get() = filter.searchQuery @@ -64,9 +64,6 @@ data class QuestionUiState( return copy(allQuestions = Questions(updatedList)) } - /** - * 특정 문제의 좋아요와 해결 상태를 업데이트 - */ fun updateQuestion( questionId: Long, isLiked: Boolean, diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt index 1a87947..8c31bda 100644 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/questions/list/vm/QuestionViewModel.kt @@ -2,7 +2,7 @@ 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.model.category.Category import com.peto.droidmorning.domain.repository.QuestionRepository import com.peto.droidmorning.questions.list.model.QuestionUiEvent import com.peto.droidmorning.questions.list.model.QuestionUiState diff --git a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/test/TestScreen.kt b/composeApp/src/commonMain/kotlin/com/peto/droidmorning/test/TestScreen.kt deleted file mode 100644 index 2701083..0000000 --- a/composeApp/src/commonMain/kotlin/com/peto/droidmorning/test/TestScreen.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.peto.droidmorning.test - -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable - -@Composable -fun TestScreen() { - Text("TestScreen") -} diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/exam/remote/DefaultRemoteExamDataSource.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/exam/remote/DefaultRemoteExamDataSource.kt new file mode 100644 index 0000000..6228b9f --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/exam/remote/DefaultRemoteExamDataSource.kt @@ -0,0 +1,69 @@ +package com.peto.droidmorning.data.datasource.exam.remote + +import com.peto.droidmorning.data.model.request.toRequest +import com.peto.droidmorning.data.model.response.ExamDetailResponse +import com.peto.droidmorning.data.model.response.ExamHistoryResponse +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.Exams +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 DefaultRemoteExamDataSource( + private val postgrest: Postgrest, + private val auth: Auth, +) : RemoteExamDataSource { + override suspend fun submitExam( + exam: Exams, + categories: List, + ): Long = + postgrest + .rpc(RPC_CREATE_EXAM, exam.toRequest(uid(), categories)) + .decodeAs() + + override suspend fun fetchExamHistory(): List = + postgrest + .from(TABLE_EXAMS) + .select(Columns.ALL) { + filter { + eq(USER_ID_COLUMN, uid()) + } + order(UPDATED_AT_COLUMN, Order.DESCENDING) + }.decodeList() + + override suspend fun fetchExamDetail(examId: Long): List { + val params = mapOf(RPC_PARAM_EXAM_ID to examId) + return postgrest + .rpc(RPC_GET_EXAM_DETAIL, params) + .decodeList() + } + + override suspend fun deleteExam(examId: Long) { + postgrest + .from(TABLE_EXAMS) + .delete { + filter { + eq(ID_COLUMN, examId) + 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_CREATE_EXAM = "create_exam_with_items" + private const val RPC_GET_EXAM_DETAIL = "get_exam_detail" + private const val RPC_PARAM_EXAM_ID = "p_exam_id" + + private const val TABLE_EXAMS = "exams" + + private const val ID_COLUMN = "id" + private const val USER_ID_COLUMN = "user_id" + private const val UPDATED_AT_COLUMN = "updated_at" + } +} diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/exam/remote/RemoteExamDataSource.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/exam/remote/RemoteExamDataSource.kt new file mode 100644 index 0000000..76cb2f4 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/datasource/exam/remote/RemoteExamDataSource.kt @@ -0,0 +1,19 @@ +package com.peto.droidmorning.data.datasource.exam.remote + +import com.peto.droidmorning.data.model.response.ExamDetailResponse +import com.peto.droidmorning.data.model.response.ExamHistoryResponse +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.Exams + +interface RemoteExamDataSource { + suspend fun submitExam( + exam: Exams, + categories: List, + ): Long + + suspend fun fetchExamHistory(): List + + suspend fun fetchExamDetail(examId: Long): List + + suspend fun deleteExam(examId: 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 dc0be1a..6eb3256 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,6 +1,9 @@ package com.peto.droidmorning.data.datasource.question.remote +import com.peto.droidmorning.data.model.request.ExamQuestionRequest import com.peto.droidmorning.data.model.request.LikeRequest +import com.peto.droidmorning.data.model.response.CategoryCountResponse +import com.peto.droidmorning.data.model.response.ExamQuestionResponse import com.peto.droidmorning.data.model.response.QuestionResponse import io.github.jan.supabase.auth.Auth import io.github.jan.supabase.postgrest.Postgrest @@ -10,17 +13,23 @@ class DefaultRemoteQuestionDataSource( private val postgrest: Postgrest, private val auth: Auth, ) : RemoteQuestionDataSource { - private fun uid(): String = - auth.currentSessionOrNull()?.user?.id - ?: throw IllegalStateException("User not logged in") - - override suspend fun fetchQuestions(): List { + override suspend fun fetchExamQuestions(): List { val params = mapOf(RPC_FETCH_QUESTIONS_PARAM_NAME to uid()) return postgrest .rpc(RPC_FETCH_QUESTIONS, params) .decodeList() } + override suspend fun fetchExamQuestions( + category: List, + count: Int, + ): List { + val params = ExamQuestionRequest(category, count) + return postgrest + .rpc(RPC_GENERATE_EXAM_QUESTIONS, params) + .decodeList() + } + override suspend fun addLike(questionId: Long) { val request = LikeRequest(uid(), questionId) postgrest @@ -39,10 +48,22 @@ class DefaultRemoteQuestionDataSource( } } + override suspend fun fetchCategoryCount(): List = + postgrest + .rpc(RPC_CATEGORY_COUNT) + .decodeList() + + private fun uid(): String = + auth.currentSessionOrNull()?.user?.id + ?: throw IllegalStateException("User not logged in") + companion object { private const val RPC_FETCH_QUESTIONS = "fetch_questions" private const val RPC_FETCH_QUESTIONS_PARAM_NAME = "uid" + private const val RPC_CATEGORY_COUNT = "fetch_question_counts_by_category" + private const val RPC_GENERATE_EXAM_QUESTIONS = "generate_exam_questions" + private const val FAVORITES_TABLE = "favorites" private const val USER_ID_COLUMN = "user_id" private const val QUESTION_ID_COLUMN = "question_id" 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 eacac68..8ab7ffe 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,11 +1,20 @@ package com.peto.droidmorning.data.datasource.question.remote +import com.peto.droidmorning.data.model.response.CategoryCountResponse +import com.peto.droidmorning.data.model.response.ExamQuestionResponse import com.peto.droidmorning.data.model.response.QuestionResponse interface RemoteQuestionDataSource { - suspend fun fetchQuestions(): List + suspend fun fetchExamQuestions(): List + + suspend fun fetchExamQuestions( + category: List, + count: Int, + ): List suspend fun addLike(questionId: Long) suspend fun removeLike(questionId: Long) + + suspend fun fetchCategoryCount(): 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 a39b8db..282f87a 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 @@ -6,6 +6,8 @@ import com.peto.droidmorning.data.datasource.auth.local.DefaultLocalAuthDataSour import com.peto.droidmorning.data.datasource.auth.local.LocalAuthDataSource import com.peto.droidmorning.data.datasource.auth.remote.DefaultRemoteAuthDataSource import com.peto.droidmorning.data.datasource.auth.remote.RemoteAuthDataSource +import com.peto.droidmorning.data.datasource.exam.remote.DefaultRemoteExamDataSource +import com.peto.droidmorning.data.datasource.exam.remote.RemoteExamDataSource import com.peto.droidmorning.data.datasource.question.remote.DefaultRemoteQuestionDataSource import com.peto.droidmorning.data.datasource.question.remote.RemoteQuestionDataSource import org.koin.dsl.module @@ -16,4 +18,5 @@ internal val dataSourceModule = single { DefaultRemoteAuthDataSource(get()) } single { DefaultRemoteQuestionDataSource(get(), get()) } single { DefaultRemoteAnswerDataSource(get(), get()) } + single { DefaultRemoteExamDataSource(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 84f1ed3..cd985a3 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 @@ -2,8 +2,10 @@ 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.DefaultExamRepository import com.peto.droidmorning.data.repository.DefaultQuestionRepository import com.peto.droidmorning.domain.repository.AnswerRepository +import com.peto.droidmorning.domain.repository.ExamRepository import com.peto.droidmorning.domain.repository.QuestionRepository import com.peto.droidmorning.domain.repository.auth.AuthRepository import org.koin.dsl.module @@ -13,4 +15,5 @@ internal val repositoryModule = single { DefaultAuthRepository(get(), get()) } single { DefaultQuestionRepository(get()) } single { DefaultAnswerRepository(get()) } + single { DefaultExamRepository(get()) } } diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamDetailRpcRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamDetailRpcRequest.kt new file mode 100644 index 0000000..f3706c3 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamDetailRpcRequest.kt @@ -0,0 +1,10 @@ +package com.peto.droidmorning.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExamDetailRpcRequest( + @SerialName("p_exam_id") + val examId: Long, +) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamItemRpcRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamItemRpcRequest.kt new file mode 100644 index 0000000..faf8511 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamItemRpcRequest.kt @@ -0,0 +1,19 @@ +package com.peto.droidmorning.data.model.request + +import com.peto.droidmorning.domain.model.exam.Exam +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExamItemRpcRequest( + @SerialName("question_id") + val questionId: Long, + @SerialName("user_answer") + val answer: String, +) + +fun Exam.toRequest(): ExamItemRpcRequest = + ExamItemRpcRequest( + questionId = questionId, + answer = answer, + ) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamQuestionRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamQuestionRequest.kt new file mode 100644 index 0000000..c29a441 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamQuestionRequest.kt @@ -0,0 +1,12 @@ +package com.peto.droidmorning.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExamQuestionRequest( + @SerialName("selected_categories") + val categories: List, + @SerialName("total_count") + val count: Int, +) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamRpcRequest.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamRpcRequest.kt new file mode 100644 index 0000000..94f904f --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/request/ExamRpcRequest.kt @@ -0,0 +1,26 @@ +package com.peto.droidmorning.data.model.request + +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.Exams +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExamRpcRequest( + val uid: String, + @SerialName("total_questions") + val questionsCount: Int, + val categories: List, + val items: List, +) + +fun Exams.toRequest( + uid: String, + categories: List, +): ExamRpcRequest = + ExamRpcRequest( + uid = uid, + questionsCount = values.size, + categories = categories, + items = values.map { it.toRequest() }, + ) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CategoryCountResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CategoryCountResponse.kt new file mode 100644 index 0000000..67881ec --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/CategoryCountResponse.kt @@ -0,0 +1,9 @@ +package com.peto.droidmorning.data.model.response + +import kotlinx.serialization.Serializable + +@Serializable +data class CategoryCountResponse( + val category: String, + val count: Long, +) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamDetailResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamDetailResponse.kt new file mode 100644 index 0000000..34f30e6 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamDetailResponse.kt @@ -0,0 +1,35 @@ +package com.peto.droidmorning.data.model.response + +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamDetail +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExamDetailResponse( + @SerialName("exam_item_id") + val examItemId: Long, + @SerialName("exam_id") + val examId: Long, + @SerialName("question_id") + val questionId: Long, + @SerialName("user_answer") + val userAnswer: String, + @SerialName("question_title") + val questionTitle: String, + @SerialName("question_category") + val questionCategory: String, + @SerialName("question_source_url") + val questionSourceUrl: String, +) + +fun ExamDetailResponse.toDomain(): ExamDetail = + ExamDetail( + examItemId = examItemId, + examId = examId, + questionId = questionId, + userAnswer = userAnswer, + questionTitle = questionTitle, + questionCategory = Category.from(questionCategory), + questionSourceUrl = questionSourceUrl, + ) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamHistoryResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamHistoryResponse.kt new file mode 100644 index 0000000..9de7694 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamHistoryResponse.kt @@ -0,0 +1,28 @@ +package com.peto.droidmorning.data.model.response + +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamHistory +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +@Serializable +data class ExamHistoryResponse( + val id: Long, + @SerialName("total_questions") + val totalQuestions: Int, + val categories: List, + @SerialName("created_at") + val createdAt: Instant, +) + +fun ExamHistoryResponse.toDomain(): ExamHistory = + ExamHistory( + id = id, + exampleCount = totalQuestions, + categories = + categories.mapNotNull { categoryString -> + runCatching { Category.from(categoryString) }.getOrNull() + }, + createdAt = createdAt, + ) diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamQuestionResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamQuestionResponse.kt new file mode 100644 index 0000000..f2782ec --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/ExamQuestionResponse.kt @@ -0,0 +1,22 @@ +package com.peto.droidmorning.data.model.response + +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamQuestion +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExamQuestionResponse( + @SerialName("question_id") + val questionId: Long, + @SerialName("question_content") + val content: String, + val category: String, +) { + fun toDomain(): ExamQuestion = + ExamQuestion( + questionId = questionId, + content = content, + category = Category.from(category), + ) +} diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt index b9be6f3..009d528 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/model/response/QuestionResponse.kt @@ -1,7 +1,7 @@ package com.peto.droidmorning.data.model.response -import com.peto.droidmorning.domain.model.Category -import com.peto.droidmorning.domain.model.Question +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.question.Question import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.time.Instant @@ -26,7 +26,7 @@ data class QuestionResponse( Question( id = id, title = title, - category = Category.name(category), + category = Category.from(category), sourceUrl = sourceUrl, createdAt = createdAt, updatedAt = updatedAt, diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultExamRepository.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultExamRepository.kt new file mode 100644 index 0000000..aab5a26 --- /dev/null +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultExamRepository.kt @@ -0,0 +1,37 @@ +package com.peto.droidmorning.data.repository + +import com.peto.droidmorning.data.datasource.exam.remote.RemoteExamDataSource +import com.peto.droidmorning.data.model.response.toDomain +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamDetail +import com.peto.droidmorning.domain.model.exam.ExamHistory +import com.peto.droidmorning.domain.model.exam.Exams +import com.peto.droidmorning.domain.repository.ExamRepository + +class DefaultExamRepository( + private val remoteExamDataSource: RemoteExamDataSource, +) : ExamRepository { + override suspend fun submitExam( + exam: Exams, + categories: List, + ): Result = runCatching { remoteExamDataSource.submitExam(exam, categories) } + + override suspend fun fetchExamHistory(): Result> = + runCatching { + remoteExamDataSource + .fetchExamHistory() + .map { it.toDomain() } + } + + override suspend fun fetchExamDetail(examId: Long): Result> = + runCatching { + remoteExamDataSource + .fetchExamDetail(examId) + .map { it.toDomain() } + } + + override suspend fun deleteExam(examId: Long): Result = + runCatching { + remoteExamDataSource.deleteExam(examId) + } +} diff --git a/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepository.kt b/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepository.kt index e453901..4fb1074 100644 --- a/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepository.kt +++ b/data/src/commonMain/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepository.kt @@ -1,7 +1,9 @@ package com.peto.droidmorning.data.repository import com.peto.droidmorning.data.datasource.question.remote.RemoteQuestionDataSource -import com.peto.droidmorning.domain.model.Questions +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamQuestion +import com.peto.droidmorning.domain.model.question.Questions import com.peto.droidmorning.domain.repository.QuestionRepository class DefaultQuestionRepository( @@ -11,7 +13,7 @@ class DefaultQuestionRepository( runCatching { val result = remoteQuestionDataSource - .fetchQuestions() + .fetchExamQuestions() .map { it.toDomain() } Questions(result) } @@ -28,4 +30,28 @@ class DefaultQuestionRepository( } true } + + override suspend fun fetchAllCategoryCount(): Result> = + runCatching { + remoteQuestionDataSource + .fetchCategoryCount() + .associate { response -> + val category = Category.from(response.category) + category to response.count + } + } + + override suspend fun fetchExamQuestions( + questionCount: Int, + categories: List, + ): Result> = + runCatching { + val categoryNames = categories.map { it.name } + + remoteQuestionDataSource + .fetchExamQuestions( + category = categoryNames, + count = questionCount, + ).map { it.toDomain() } + } } 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 25994ce..12d1529 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,14 +1,30 @@ package com.peto.droidmorning.data.fake import com.peto.droidmorning.data.datasource.question.remote.RemoteQuestionDataSource +import com.peto.droidmorning.data.model.response.CategoryCountResponse +import com.peto.droidmorning.data.model.response.ExamQuestionResponse import com.peto.droidmorning.data.model.response.QuestionResponse class FakeRemoteQuestionDataSource( - private val questions: List, + private val questions: List = emptyList(), + private val examQuestions: List = emptyList(), + private val categoryCount: List = emptyList(), ) : RemoteQuestionDataSource { private val likedQuestions = mutableSetOf() - override suspend fun fetchQuestions(): List = questions + override suspend fun fetchExamQuestions(): List = questions + + override suspend fun fetchExamQuestions( + category: List, + count: Int, + ): List = + if (category.isEmpty()) { + examQuestions.take(count) + } else { + examQuestions + .filter { it.category in category } + .take(count) + } override suspend fun addLike(questionId: Long) { likedQuestions.add(questionId) @@ -18,6 +34,8 @@ class FakeRemoteQuestionDataSource( likedQuestions.remove(questionId) } + override suspend fun fetchCategoryCount(): List = categoryCount + fun isLiked(questionId: Long): Boolean = likedQuestions.contains(questionId) fun clearLikes() { diff --git a/data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSourceTest.kt b/data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSourceTest.kt new file mode 100644 index 0000000..c0dff50 --- /dev/null +++ b/data/src/commonTest/kotlin/com/peto/droidmorning/data/fake/FakeRemoteQuestionDataSourceTest.kt @@ -0,0 +1,163 @@ +package com.peto.droidmorning.data.fake + +import com.peto.droidmorning.data.fixture.QuestionResponseFixture +import com.peto.droidmorning.domain.model.category.Category +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FakeRemoteQuestionDataSourceTest { + private lateinit var dataSource: FakeRemoteQuestionDataSource + + @BeforeTest + fun setup() { + dataSource = + FakeRemoteQuestionDataSource( + questions = QuestionResponseFixture.questionResponseList(3), + examQuestions = QuestionResponseFixture.examQuestionResponseList(10), + categoryCount = QuestionResponseFixture.categoryCountResponseList(), + ) + } + + @Test + fun `fetchExamQuestions는 모든 질문을 반환한다`() = + runTest { + // When + val result = dataSource.fetchExamQuestions() + + // Then + assertEquals(3, result.size) + assertEquals("질문 1", result[0].title) + } + + @Test + fun `fetchExamQuestions with categories는 지정된 카테고리의 질문만 반환한다`() = + runTest { + // Given + val categories = listOf(Category.Kotlin.name, Category.Android.name) + val count = 5 + + // When + val result = dataSource.fetchExamQuestions(category = categories, count = count) + + // Then + assertTrue(result.size <= count) + assertTrue(result.all { it.category in categories }) + } + + @Test + fun `fetchExamQuestions with empty categories는 모든 카테고리에서 count만큼 반환한다`() = + runTest { + // Given + val count = 3 + + // When + val result = dataSource.fetchExamQuestions(category = emptyList(), count = count) + + // Then + assertEquals(count, result.size) + } + + @Test + fun `fetchExamQuestions는 count보다 적은 질문이 있으면 가능한 만큼만 반환한다`() = + runTest { + // Given + val smallDataSource = + FakeRemoteQuestionDataSource( + examQuestions = QuestionResponseFixture.examQuestionResponseList(3), + ) + val count = 10 + + // When + val result = smallDataSource.fetchExamQuestions(category = emptyList(), count = count) + + // Then + assertEquals(3, result.size) + } + + @Test + fun `addLike는 질문 ID를 좋아요 목록에 추가한다`() = + runTest { + // Given + val questionId = 1L + + // When + dataSource.addLike(questionId) + + // Then + assertTrue(dataSource.isLiked(questionId)) + } + + @Test + fun `removeLike는 질문 ID를 좋아요 목록에서 제거한다`() = + runTest { + // Given + val questionId = 1L + dataSource.addLike(questionId) + + // When + dataSource.removeLike(questionId) + + // Then + assertFalse(dataSource.isLiked(questionId)) + } + + @Test + fun `clearLikes는 모든 좋아요를 제거한다`() = + runTest { + // Given + dataSource.addLike(1L) + dataSource.addLike(2L) + dataSource.addLike(3L) + + // When + dataSource.clearLikes() + + // Then + assertFalse(dataSource.isLiked(1L)) + assertFalse(dataSource.isLiked(2L)) + assertFalse(dataSource.isLiked(3L)) + } + + @Test + fun `fetchCategoryCount는 모든 카테고리의 개수를 반환한다`() = + runTest { + // When + val result = dataSource.fetchCategoryCount() + + // Then + assertEquals(Category.entries.size, result.size) + assertEquals(Category.Kotlin.name, result[0].category) + assertEquals(10L, result[0].count) + } + + @Test + fun `isLiked는 좋아요하지 않은 질문에 대해 false를 반환한다`() { + // Given + val questionId = 999L + + // When + val result = dataSource.isLiked(questionId) + + // Then + assertFalse(result) + } + + @Test + fun `동일한 질문에 여러 번 addLike를 호출해도 중복되지 않는다`() = + runTest { + // Given + val questionId = 1L + + // When + dataSource.addLike(questionId) + dataSource.addLike(questionId) + dataSource.addLike(questionId) + + // Then + assertTrue(dataSource.isLiked(questionId)) + } +} 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 99e157c..e14c7aa 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,7 +1,9 @@ package com.peto.droidmorning.data.fixture +import com.peto.droidmorning.data.model.response.CategoryCountResponse +import com.peto.droidmorning.data.model.response.ExamQuestionResponse import com.peto.droidmorning.data.model.response.QuestionResponse -import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.category.Category import kotlin.time.Instant object QuestionResponseFixture { @@ -31,4 +33,41 @@ object QuestionResponseFixture { title = "질문 $it", ) } + + fun examQuestionResponse( + questionId: Long = 1L, + content: String = "시험 질문 내용", + category: Category = Category.Kotlin, + ): ExamQuestionResponse = + ExamQuestionResponse( + questionId = questionId, + content = content, + category = category.name, + ) + + fun examQuestionResponseList(size: Int = 5): List = + (1..size).map { + examQuestionResponse( + questionId = it.toLong(), + content = "시험 질문 $it", + category = Category.entries[it % Category.entries.size], + ) + } + + fun categoryCountResponse( + category: Category = Category.Kotlin, + count: Long = 10L, + ): CategoryCountResponse = + CategoryCountResponse( + category = category.name, + count = count, + ) + + fun categoryCountResponseList(): List = + Category.entries.mapIndexed { index, category -> + categoryCountResponse( + category = category, + count = (index + 1) * 10L, + ) + } } diff --git a/data/src/commonTest/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepositoryTest.kt b/data/src/commonTest/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepositoryTest.kt index 89247d8..064004e 100644 --- a/data/src/commonTest/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepositoryTest.kt +++ b/data/src/commonTest/kotlin/com/peto/droidmorning/data/repository/DefaultQuestionRepositoryTest.kt @@ -53,7 +53,6 @@ class DefaultQuestionRepositoryTest { val repository = DefaultQuestionRepository(fakeDataSource) val questionId = 1L - // 먼저 좋아요 추가 fakeDataSource.addLike(questionId) // when diff --git a/designsystem/src/commonMain/composeResources/values/strings.xml b/designsystem/src/commonMain/composeResources/values/strings.xml index 8cac743..3e8409b 100644 --- a/designsystem/src/commonMain/composeResources/values/strings.xml +++ b/designsystem/src/commonMain/composeResources/values/strings.xml @@ -1,6 +1,8 @@ Droid Morning + 삭제 + 취소 풀이 완료 @@ -23,6 +25,10 @@ 문제 + + 질문 + 내 답변 + 입력하세요 diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/CategoryBadge.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/CategoryBadge.kt index 4c3641d..4d8f171 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/CategoryBadge.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/CategoryBadge.kt @@ -17,7 +17,7 @@ import com.peto.droidmorning.designsystem.theme.CategoryKotlin import com.peto.droidmorning.designsystem.theme.CategoryOOP import com.peto.droidmorning.designsystem.theme.Dimen import com.peto.droidmorning.designsystem.theme.Shape -import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.category.Category @Composable fun CategoryBadge( 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 index c0b5986..4dc1d64 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmDialog.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmDialog.kt @@ -28,9 +28,13 @@ 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.generated.resources.DesignRes +import com.peto.droidmorning.designsystem.generated.resources.cancel +import com.peto.droidmorning.designsystem.generated.resources.remove import com.peto.droidmorning.designsystem.theme.AppTheme import com.peto.droidmorning.designsystem.theme.Dimen import com.peto.droidmorning.designsystem.theme.Shape +import org.jetbrains.compose.resources.stringResource @Composable fun ConfirmDialog( @@ -38,8 +42,8 @@ fun ConfirmDialog( onConfirm: () -> Unit, title: String, message: String, - confirmText: String, - cancelText: String, + confirmText: String = stringResource(DesignRes.string.remove), + cancelText: String = stringResource(DesignRes.string.cancel), icon: ImageVector = Icons.Default.Error, iconTint: Color = MaterialTheme.colorScheme.error, iconBackgroundColor: Color = MaterialTheme.colorScheme.errorContainer, diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmationDialog.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmationDialog.kt deleted file mode 100644 index 9248dec..0000000 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ConfirmationDialog.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.peto.droidmorning.designsystem.component - -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Warning -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import com.peto.droidmorning.designsystem.generated.resources.DesignRes -import com.peto.droidmorning.designsystem.generated.resources.confirmation_dialog_default_confirm -import com.peto.droidmorning.designsystem.generated.resources.confirmation_dialog_default_dismiss -import com.peto.droidmorning.designsystem.theme.AppTheme -import org.jetbrains.compose.resources.stringResource - -@Composable -fun ConfirmationDialog( - onDismissRequest: () -> Unit, - onConfirm: () -> Unit, - title: String, - message: String, - confirmText: String = stringResource(DesignRes.string.confirmation_dialog_default_confirm), - dismissText: String = stringResource(DesignRes.string.confirmation_dialog_default_dismiss), - isDestructive: Boolean = false, -) { - val confirmColor = - if (isDestructive) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - } - - AppAlertDialog( - onDismissRequest = onDismissRequest, - title = title, - text = message, - icon = if (isDestructive) Icons.Default.Warning else null, - iconTint = confirmColor, - confirmButton = { - TextButton( - onClick = { - onConfirm() - onDismissRequest() - }, - colors = - ButtonDefaults.textButtonColors( - contentColor = confirmColor, - ), - ) { - Text( - text = confirmText, - fontWeight = FontWeight.SemiBold, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text( - text = dismissText, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - ) -} - -@Preview -@Composable -private fun ConfirmationDialogPreview() { - AppTheme { - ConfirmationDialog( - onDismissRequest = {}, - onConfirm = {}, - title = "로그아웃", - message = "정말 로그아웃할까요?", - confirmText = "로그아웃", - dismissText = "취소", - isDestructive = false, - ) - } -} diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ExamQuestionCard.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ExamQuestionCard.kt new file mode 100644 index 0000000..a72a508 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/ExamQuestionCard.kt @@ -0,0 +1,172 @@ +package com.peto.droidmorning.designsystem.component + +import androidx.compose.foundation.background +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.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Quiz +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.peto.droidmorning.designsystem.extension.color +import com.peto.droidmorning.designsystem.generated.resources.DesignRes +import com.peto.droidmorning.designsystem.generated.resources.exam_question_card_answer_label +import com.peto.droidmorning.designsystem.generated.resources.exam_question_card_question_label +import com.peto.droidmorning.designsystem.theme.AppTheme +import com.peto.droidmorning.designsystem.theme.Dimen +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamDetail +import org.jetbrains.compose.resources.stringResource + +@Composable +fun ExamQuestionCard( + questionNumber: Int, + examDetail: ExamDetail, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(Dimen.radiusBase), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = + CardDefaults.cardElevation( + defaultElevation = Dimen.cardElevation, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(Dimen.spacingBase), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CategoryBadge( + category = examDetail.questionCategory, + categoryColor = examDetail.questionCategory.color, + ) + Text( + text = "#$questionNumber", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(Dimen.radiusMd)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(Dimen.spacingBase), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingSm), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXs), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Quiz, + contentDescription = null, + modifier = Modifier.size(Dimen.iconXs), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringResource(DesignRes.string.exam_question_card_question_label), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + Text( + text = examDetail.questionTitle, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(Dimen.spacingMd)) + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Dimen.spacingBase) + .padding(bottom = Dimen.spacingBase), + verticalArrangement = Arrangement.spacedBy(Dimen.spacingSm), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(Dimen.spacingXs), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Edit, + contentDescription = null, + modifier = Modifier.size(Dimen.iconXs), + tint = MaterialTheme.colorScheme.onSecondary, + ) + Text( + text = stringResource(DesignRes.string.exam_question_card_answer_label), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondary, + ) + } + + Text( + text = examDetail.userAnswer, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Preview +@Composable +private fun ExamQuestionCardWithAnswerPreview() { + AppTheme { + ExamQuestionCard( + questionNumber = 1, + examDetail = + ExamDetail( + examItemId = 1L, + examId = 1L, + questionId = 1L, + userAnswer = "ViewModel은 UI 관련 데이터를 관리하고, 화면 회전과 같은 구성 변경에도 데이터를 유지하는 역할을 합니다.", + questionTitle = "Android에서 ViewModel의 역할은 무엇인가요?", + questionCategory = Category.Android, + questionSourceUrl = "https://example.com/question1", + ), + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/QuestionCard.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/QuestionCard.kt index f098941..9c3fe09 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/QuestionCard.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/component/QuestionCard.kt @@ -38,7 +38,7 @@ import com.peto.droidmorning.designsystem.theme.MutedForeground import com.peto.droidmorning.designsystem.theme.OnSurface import com.peto.droidmorning.designsystem.theme.Success import com.peto.droidmorning.designsystem.theme.Warning -import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.category.Category import org.jetbrains.compose.resources.stringResource @Composable 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 index 9bf72c5..6ce84cc 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/extension/CategoryExtensions.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/extension/CategoryExtensions.kt @@ -6,7 +6,7 @@ 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 +import com.peto.droidmorning.domain.model.category.Category val Category.color: Color get() = diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/preview/QuestionCardPreviewProvider.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/preview/QuestionCardPreviewProvider.kt index 5e0a9be..9c5abef 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/preview/QuestionCardPreviewProvider.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/preview/QuestionCardPreviewProvider.kt @@ -1,7 +1,7 @@ package com.peto.droidmorning.designsystem.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.peto.droidmorning.domain.model.Category +import com.peto.droidmorning.domain.model.category.Category data class QuestionCardPreviewState( val title: String, diff --git a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/AppTheme.kt b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/AppTheme.kt index c4f71f0..f28df58 100644 --- a/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/AppTheme.kt +++ b/designsystem/src/commonMain/kotlin/com/peto/droidmorning/designsystem/theme/AppTheme.kt @@ -6,22 +6,63 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +/** + * Extended colors for DroidMorning app + */ +data class ExtendedColors( + val examSelected: Color = ExamSelected, + val examCorrect: Color = ExamCorrect, + val examCorrectBackground: Color = ExamCorrectBackground, +) + +private val LightExtendedColors = + ExtendedColors( + examSelected = ExamSelected, + examCorrect = ExamCorrect, + examCorrectBackground = ExamCorrectBackground, + ) + +private val DarkExtendedColors = + ExtendedColors( + examSelected = ExamSelectedDark, + examCorrect = ExamCorrectDark, + examCorrectBackground = ExamCorrectBackgroundDark, + ) + +val LocalExtendedColors = staticCompositionLocalOf { LightExtendedColors } + @Composable fun AppTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), colorScheme: ColorScheme = if (useDarkTheme) DarkColorScheme else LightColorScheme, content: @Composable () -> Unit, ) { - MaterialTheme( - colorScheme = colorScheme, - typography = droidMorningTypography(), - shapes = Shapes, - content = content, - ) + val extendedColors = if (useDarkTheme) DarkExtendedColors else LightExtendedColors + + androidx.compose.runtime.CompositionLocalProvider( + LocalExtendedColors provides extendedColors, + ) { + MaterialTheme( + colorScheme = colorScheme, + typography = droidMorningTypography(), + shapes = Shapes, + content = content, + ) + } } +/** + * Extension property to access extended colors + */ +val MaterialTheme.extendedColors: ExtendedColors + @Composable + @ReadOnlyComposable + get() = LocalExtendedColors.current + private val LightColorScheme = lightColorScheme( primary = Primary, 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 9ff5c98..6ecbf05 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 @@ -78,3 +78,16 @@ val CategoryAndroid = Color(0xFF01BD56) val CategoryCompose = Color(0xFFD9B110) val CategoryCoroutine = Color(0xFF01C4C6) val CategoryOOP = Color(0xFFD77701) + +// Exam colors +val ExamSelected = Color(0xFFFF9800) +val ExamUnSelected = Color(0xFFFCEEDA) +val ExamSelectedDark = Color(0xFFFFB74D) + +// Exam result colors +val ExamCorrect = Color(0xFF4CAF50) +val ExamCorrectBackground = Color(0xFFE8F5E9) + +// Dark mode variants +val ExamCorrectDark = Color(0xFF81C784) +val ExamCorrectBackgroundDark = Color(0xFF1B5E20) diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Category.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Category.kt deleted file mode 100644 index da5a846..0000000 --- a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Category.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.peto.droidmorning.domain.model - -enum class Category { - Kotlin, - Coroutine, - Android, - Compose, - OOP, - ; - - companion object { - fun name(value: String): Category = - entries - .firstOrNull { it.name.equals(value, ignoreCase = true) } - ?: throw IllegalArgumentException("Unknown category: $value") - } -} diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Filter.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Filter.kt index fe8e5cf..63edc06 100644 --- a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Filter.kt +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Filter.kt @@ -1,5 +1,8 @@ package com.peto.droidmorning.domain.model +import com.peto.droidmorning.domain.model.category.Categories +import com.peto.droidmorning.domain.model.category.Category + data class Filter( val searchQuery: SearchQuery = SearchQuery(""), val categories: Categories = Categories(emptySet()), diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Categories.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Categories.kt similarity index 88% rename from domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Categories.kt rename to domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Categories.kt index 01b9c70..4893591 100644 --- a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Categories.kt +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Categories.kt @@ -1,4 +1,4 @@ -package com.peto.droidmorning.domain.model +package com.peto.droidmorning.domain.model.category data class Categories( private val values: Set = emptySet(), diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Category.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Category.kt new file mode 100644 index 0000000..9691f5c --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/category/Category.kt @@ -0,0 +1,19 @@ +package com.peto.droidmorning.domain.model.category + +enum class Category { + Kotlin, + Coroutine, + Android, + Compose, + OOP, + ; + + companion object { + fun from(value: String): Category = + entries + .find { it.name.equals(value, ignoreCase = true) } + ?: throw IllegalArgumentException("$UNKNOWN_CATEGORY_EXCEPTION $value") + + private const val UNKNOWN_CATEGORY_EXCEPTION = "Unknown category:" + } +} diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/Exam.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/Exam.kt new file mode 100644 index 0000000..9fe7127 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/Exam.kt @@ -0,0 +1,6 @@ +package com.peto.droidmorning.domain.model.exam + +data class Exam( + val questionId: Long, + val answer: String, +) diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamDetail.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamDetail.kt new file mode 100644 index 0000000..af0fd00 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamDetail.kt @@ -0,0 +1,13 @@ +package com.peto.droidmorning.domain.model.exam + +import com.peto.droidmorning.domain.model.category.Category + +data class ExamDetail( + val examItemId: Long, + val examId: Long, + val questionId: Long, + val userAnswer: String, + val questionTitle: String, + val questionCategory: Category, + val questionSourceUrl: String, +) diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamHistory.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamHistory.kt new file mode 100644 index 0000000..1ba02d7 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamHistory.kt @@ -0,0 +1,11 @@ +package com.peto.droidmorning.domain.model.exam + +import com.peto.droidmorning.domain.model.category.Category +import kotlin.time.Instant + +data class ExamHistory( + val id: Long, + val exampleCount: Int, + val categories: List, + val createdAt: Instant, +) diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamQuestion.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamQuestion.kt new file mode 100644 index 0000000..aa3231d --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/ExamQuestion.kt @@ -0,0 +1,9 @@ +package com.peto.droidmorning.domain.model.exam + +import com.peto.droidmorning.domain.model.category.Category + +data class ExamQuestion( + val questionId: Long, + val content: String, + val category: Category, +) diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/Exams.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/Exams.kt new file mode 100644 index 0000000..89b339f --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/exam/Exams.kt @@ -0,0 +1,24 @@ +package com.peto.droidmorning.domain.model.exam + +data class Exams( + val values: List = emptyList(), +) { + operator fun get(questionId: Long): String = values.firstOrNull { it.questionId == questionId }?.answer.orEmpty() + + fun updateAnswer( + questionId: Long, + answer: String, + ): Exams { + val existingExamIndex = values.indexOfFirst { it.questionId == questionId } + + return if (existingExamIndex >= 0) { + val updatedValues = + values.toMutableList().apply { + set(existingExamIndex, Exam(questionId, answer)) + } + copy(values = updatedValues) + } else { + copy(values = values + Exam(questionId, answer)) + } + } +} diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Question.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/question/Question.kt similarity index 68% rename from domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Question.kt rename to domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/question/Question.kt index 14ef2e2..1d7d41f 100644 --- a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Question.kt +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/question/Question.kt @@ -1,5 +1,7 @@ -package com.peto.droidmorning.domain.model +package com.peto.droidmorning.domain.model.question +import com.peto.droidmorning.domain.model.SearchQuery +import com.peto.droidmorning.domain.model.category.Category import kotlin.time.Instant data class Question( diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Questions.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/question/Questions.kt similarity index 86% rename from domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Questions.kt rename to domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/question/Questions.kt index 34a3bfa..9183279 100644 --- a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/Questions.kt +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/model/question/Questions.kt @@ -1,4 +1,8 @@ -package com.peto.droidmorning.domain.model +package com.peto.droidmorning.domain.model.question + +import com.peto.droidmorning.domain.model.Filter +import com.peto.droidmorning.domain.model.SearchQuery +import com.peto.droidmorning.domain.model.category.Categories data class Questions( private val values: List, diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/ExamRepository.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/ExamRepository.kt new file mode 100644 index 0000000..157d263 --- /dev/null +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/ExamRepository.kt @@ -0,0 +1,19 @@ +package com.peto.droidmorning.domain.repository + +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamDetail +import com.peto.droidmorning.domain.model.exam.ExamHistory +import com.peto.droidmorning.domain.model.exam.Exams + +interface ExamRepository { + suspend fun submitExam( + exam: Exams, + categories: List, + ): Result + + suspend fun fetchExamHistory(): Result> + + suspend fun fetchExamDetail(examId: Long): Result> + + suspend fun deleteExam(examId: Long): Result +} diff --git a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/QuestionRepository.kt b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/QuestionRepository.kt index d8760ab..c4017db 100644 --- a/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/QuestionRepository.kt +++ b/domain/src/commonMain/kotlin/com/peto/droidmorning/domain/repository/QuestionRepository.kt @@ -1,12 +1,21 @@ package com.peto.droidmorning.domain.repository -import com.peto.droidmorning.domain.model.Questions +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.exam.ExamQuestion +import com.peto.droidmorning.domain.model.question.Questions interface QuestionRepository { suspend fun fetchQuestions(): Result + suspend fun fetchExamQuestions( + questionCount: Int, + categories: List, + ): Result> + suspend fun toggleQuestionLike( questionId: Long, isCurrentlyLiked: Boolean, ): Result + + suspend fun fetchAllCategoryCount(): Result> } diff --git a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoriesTest.kt b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoriesTest.kt index 7b575b1..456b784 100644 --- a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoriesTest.kt +++ b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoriesTest.kt @@ -1,6 +1,8 @@ package com.peto.droidmorning.domain.model import com.peto.droidmorning.domain.assertAll +import com.peto.droidmorning.domain.model.category.Categories +import com.peto.droidmorning.domain.model.category.Category import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoryTest.kt b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoryTest.kt index 95ba271..b22f1ca 100644 --- a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoryTest.kt +++ b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/CategoryTest.kt @@ -1,5 +1,6 @@ package com.peto.droidmorning.domain.model +import com.peto.droidmorning.domain.model.category.Category import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -22,7 +23,7 @@ class CategoryTest { // Given // When - val result = Category.name(validCategoryName) + val result = Category.from(validCategoryName) // Then assertEquals(Category.Kotlin, result) @@ -33,7 +34,7 @@ class CategoryTest { // Given // When - val result = Category.name(uppercaseCategoryName) + val result = Category.from(uppercaseCategoryName) // Then assertEquals(Category.Android, result) @@ -44,7 +45,7 @@ class CategoryTest { // Given // When - val result = Category.name(mixedCaseCategoryName) + val result = Category.from(mixedCaseCategoryName) // Then assertEquals(Category.Compose, result) @@ -56,7 +57,7 @@ class CategoryTest { val categoryName = "coroutine" // When - val result = Category.name(categoryName) + val result = Category.from(categoryName) // Then assertEquals(Category.Coroutine, result) @@ -68,7 +69,7 @@ class CategoryTest { val categoryName = "oop" // When - val result = Category.name(categoryName) + val result = Category.from(categoryName) // Then assertEquals(Category.OOP, result) @@ -82,7 +83,7 @@ class CategoryTest { // When & Then val exception = assertFailsWith { - Category.name(invalidCategoryName) + Category.from(invalidCategoryName) } assertEquals("Unknown category: InvalidCategory", exception.message) } @@ -94,7 +95,7 @@ class CategoryTest { // When & Then assertFailsWith { - Category.name(emptyCategoryName) + Category.from(emptyCategoryName) } } } diff --git a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/FilterTest.kt b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/FilterTest.kt index bd3abd6..bf30ac7 100644 --- a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/FilterTest.kt +++ b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/FilterTest.kt @@ -1,6 +1,8 @@ package com.peto.droidmorning.domain.model import com.peto.droidmorning.domain.assertAll +import com.peto.droidmorning.domain.model.category.Categories +import com.peto.droidmorning.domain.model.category.Category import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionTest.kt b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionTest.kt index 3b9026f..85f48ee 100644 --- a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionTest.kt +++ b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionTest.kt @@ -1,5 +1,7 @@ package com.peto.droidmorning.domain.model +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.question.Question import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse diff --git a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionsTest.kt b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionsTest.kt index c4f8487..9459135 100644 --- a/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionsTest.kt +++ b/domain/src/commonTest/kotlin/com/peto/droidmorning/domain/model/QuestionsTest.kt @@ -1,6 +1,10 @@ package com.peto.droidmorning.domain.model import com.peto.droidmorning.domain.assertAll +import com.peto.droidmorning.domain.model.category.Categories +import com.peto.droidmorning.domain.model.category.Category +import com.peto.droidmorning.domain.model.question.Question +import com.peto.droidmorning.domain.model.question.Questions import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -192,10 +196,30 @@ class QuestionsTest { val questions = Questions( listOf( - createQuestion(id = 1, title = "Kotlin Basics", category = Category.Kotlin, isSolved = true), - createQuestion(id = 2, title = "Android Basics", category = Category.Android, isSolved = true), - createQuestion(id = 3, title = "Kotlin Advanced", category = Category.Kotlin, isSolved = false), - createQuestion(id = 4, title = "Kotlin Expert", category = Category.Kotlin, isSolved = true), + createQuestion( + id = 1, + title = "Kotlin Basics", + category = Category.Kotlin, + isSolved = true, + ), + createQuestion( + id = 2, + title = "Android Basics", + category = Category.Android, + isSolved = true, + ), + createQuestion( + id = 3, + title = "Kotlin Advanced", + category = Category.Kotlin, + isSolved = false, + ), + createQuestion( + id = 4, + title = "Kotlin Expert", + category = Category.Kotlin, + isSolved = true, + ), ), ) val filter =