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