diff --git a/app/schemas/com.android.quizcafe.core.database.QuizCafeDatabase/1.json b/app/schemas/com.android.quizcafe.core.database.QuizCafeDatabase/1.json index b22efd7f..ff6c0e3f 100644 --- a/app/schemas/com.android.quizcafe.core.database.QuizCafeDatabase/1.json +++ b/app/schemas/com.android.quizcafe.core.database.QuizCafeDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "e8b0c98f11a3e10bbae77fa64b2c1a67", + "identityHash": "cb35ad76919b5cac5c41199d7b88c43a", "entities": [ { "tableName": "quiz", @@ -251,7 +251,17 @@ "localId" ] }, - "indices": [], + "indices": [ + { + "name": "index_QuizBookGradeEntity_quizBookId", + "unique": true, + "columnNames": [ + "quizBookId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_QuizBookGradeEntity_quizBookId` ON `${TABLE_NAME}` (`quizBookId`)" + } + ], "foreignKeys": [] }, { @@ -324,7 +334,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e8b0c98f11a3e10bbae77fa64b2c1a67')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cb35ad76919b5cac5c41199d7b88c43a')" ] } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/android/quizcafe/feature/quiz/solve/FakeQuizSolveViewModel.kt b/app/src/androidTest/java/com/android/quizcafe/feature/quiz/solve/FakeQuizSolveViewModel.kt new file mode 100644 index 00000000..64b7f19d --- /dev/null +++ b/app/src/androidTest/java/com/android/quizcafe/feature/quiz/solve/FakeQuizSolveViewModel.kt @@ -0,0 +1,35 @@ +package com.android.quizcafe.feature.quiz.solve + +import com.android.quizcafe.feature.quiz.solve.viewmodel.IQuizSolveViewModel +import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveEffect +import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveIntent +import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveUiState +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeQuizSolveViewModel : IQuizSolveViewModel { + private val _state = MutableStateFlow(QuizSolveUiState()) + override val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + override val effect: SharedFlow = _effect.asSharedFlow() + + val receivedIntents = mutableListOf() + + override fun sendIntent(intent: QuizSolveIntent) { + receivedIntents.add(intent) + } + + // 테스트 편의를 위한 함수들 (인터페이스에는 포함되지 않음) + suspend fun emitEffect(effect: QuizSolveEffect) { + _effect.emit(effect) + } + + fun setState(newState: QuizSolveUiState) { + _state.value = newState + } +} diff --git a/app/src/androidTest/java/com/android/quizcafe/feature/quiz/solve/QuizSolveScreenTest.kt b/app/src/androidTest/java/com/android/quizcafe/feature/quiz/solve/QuizSolveScreenTest.kt new file mode 100644 index 00000000..d5d31f31 --- /dev/null +++ b/app/src/androidTest/java/com/android/quizcafe/feature/quiz/solve/QuizSolveScreenTest.kt @@ -0,0 +1,57 @@ +package com.android.quizcafe.feature.quiz.solve + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.espresso.Espresso +import com.android.quizcafe.core.ui.util.TestTags +import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveEffect +import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveIntent +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class QuizSolveScreenTest { + + // 컴포즈 테스트 환경 설정 규칙 + @get:Rule + val composeTestRule = createComposeRule() + private lateinit var fakeViewModel: FakeQuizSolveViewModel + + @Before + fun setUp() { + fakeViewModel = FakeQuizSolveViewModel() + } + + // 실제 앱에서는 ViewModel에서 이 로직을 처리하지만 테스트에서는 onIntent 람다에서 직접 상태를 변경하여 ViewModel의 동작 처리 + @Test + fun whenBackPressed_showsExitDialog_viaViewModelEffect() = runTest { + var wasNavigateBackCalled = false + + composeTestRule.setContent { + QuizSolveRoute( + quizBookId = 1L, + navigateToBack = { wasNavigateBackCalled = true }, + navigateToQuizBookSolvingResult = {}, + viewModel = fakeViewModel + ) + } + + composeTestRule.onNodeWithTag(TestTags.QuizSolve.EXIT_DIALOG).assertDoesNotExist() + + Espresso.pressBack() +// Thread.sleep(2000) // 디버깅용 + assertTrue(fakeViewModel.receivedIntents.any { it is QuizSolveIntent.OnBackClick }) + + // ViewModel 동작 시뮬레이션 및 UI 검증 + fakeViewModel.emitEffect(QuizSolveEffect.ShowExitDialog) + composeTestRule.onNodeWithTag(TestTags.QuizSolve.EXIT_DIALOG).assertIsDisplayed() + + // 다이얼로그 내 버튼 클릭 및 검증 + composeTestRule.onNodeWithTag(TestTags.QuizSolve.EXIT_DIALOG_EXIT_SAVE_BUTTON).performClick() + assertTrue(fakeViewModel.receivedIntents.any { it is QuizSolveIntent.ExitWithSave }) + } +} diff --git a/app/src/androidTest/java/com/android/quizcafe/repository/QuizBookSolvingRepositoryTest.kt b/app/src/androidTest/java/com/android/quizcafe/repository/QuizBookSolvingRepositoryTest.kt index e33995f6..93c669f8 100644 --- a/app/src/androidTest/java/com/android/quizcafe/repository/QuizBookSolvingRepositoryTest.kt +++ b/app/src/androidTest/java/com/android/quizcafe/repository/QuizBookSolvingRepositoryTest.kt @@ -84,7 +84,7 @@ class QuizBookSolvingRepositoryTest { setupTestData(quizBookId) val results = mutableListOf>() - repository.createEmptyQuizBookGrade(quizBookId).collect(results::add) + repository.getOrCreateQuizBookGrade(quizBookId).collect(results::add) assertEquals(2, results.size) assertTrue(results[0] is Resource.Loading) @@ -98,8 +98,8 @@ class QuizBookSolvingRepositoryTest { setupTestData(quizBookId) // 먼저 퀴즈북 풀이 생성 - val quizBookGradeLocalId = repository.createEmptyQuizBookGrade(quizBookId).first { it is Resource.Success } - .let { (it as Resource.Success).data } + val quizBookGradeLocalId = repository.getOrCreateQuizBookGrade(quizBookId).first { it is Resource.Success } + .let { (it as Resource.Success).data }.localId val quizGrade = QuizGrade( localId = 1L, @@ -140,7 +140,7 @@ class QuizBookSolvingRepositoryTest { assertEquals(quizGrade.memo, savedQuizGrade.memo) // Repository를 통해 조회한 데이터와도 비교 - val repositoryResult = repository.getQuizBookGrade(quizBookGradeLocalId) + val repositoryResult = repository.getOrCreateQuizBookGrade(quizBookId) .first { it is Resource.Success } .let { (it as Resource.Success).data } @@ -162,9 +162,9 @@ class QuizBookSolvingRepositoryTest { val quizBookId = QuizBookId(1L) setupTestData(quizBookId) - val quizBookGradeLocalId = repository.createEmptyQuizBookGrade(quizBookId) + val quizBookGradeLocalId = repository.getOrCreateQuizBookGrade(quizBookId) .first { it is Resource.Success } - .let { (it as Resource.Success).data } + .let { (it as Resource.Success).data }.localId // 3개 문제 중 1번만 틀리고 나머지는 맞음 val quizGrades = listOf( @@ -204,7 +204,7 @@ class QuizBookSolvingRepositoryTest { // When val solveResults = mutableListOf>() - repository.solveQuizBook(quizBookGradeLocalId).collect(solveResults::add) + repository.solveQuizBook(quizBookGradeLocalId, 1L).collect(solveResults::add) // Then assertEquals(2, solveResults.size) diff --git a/app/src/main/java/com/android/quizcafe/core/data/mapper/quizbook/QuizBookMapper.kt b/app/src/main/java/com/android/quizcafe/core/data/mapper/quizbook/QuizBookMapper.kt index 7a17064a..346bed13 100644 --- a/app/src/main/java/com/android/quizcafe/core/data/mapper/quizbook/QuizBookMapper.kt +++ b/app/src/main/java/com/android/quizcafe/core/data/mapper/quizbook/QuizBookMapper.kt @@ -7,9 +7,10 @@ import com.android.quizcafe.core.data.model.quizbook.response.QuizBookWithQuizze import com.android.quizcafe.core.database.model.quizbook.QuizBookEntity import com.android.quizcafe.core.database.model.quizbook.QuizBookWithQuizRelation import com.android.quizcafe.core.domain.model.quizbook.response.QuizBook +import com.android.quizcafe.core.domain.model.value.QuizBookId fun QuizBookEntity.toDomain() = QuizBook( - id = id, + id = QuizBookId(id), version = version, category = category, title = title, @@ -23,7 +24,7 @@ fun QuizBookEntity.toDomain() = QuizBook( ) fun QuizBookResponseDto.toDomain() = QuizBook( - id = quizBookId, + id = QuizBookId(quizBookId), version = version, category = category, description = description, @@ -49,7 +50,7 @@ fun QuizBookWithQuizzesResponseDto.toEntity() = QuizBookEntity( ) fun QuizBookWithQuizzesResponseDto.toDomain() = QuizBook( - id = quizBookId, + id = QuizBookId(quizBookId), version = version, category = category, description = description, @@ -64,7 +65,7 @@ fun QuizBookWithQuizzesResponseDto.toDomain() = QuizBook( ) fun QuizBookWithQuizRelation.toDomain() = QuizBook( - id = quizBookEntity.id, + id = QuizBookId(quizBookEntity.id), version = quizBookEntity.version, category = quizBookEntity.category, title = quizBookEntity.title, diff --git a/app/src/main/java/com/android/quizcafe/core/data/repository/QuizBookSolvingRepositoryImpl.kt b/app/src/main/java/com/android/quizcafe/core/data/repository/QuizBookSolvingRepositoryImpl.kt index 5454c2e6..fd6174d8 100644 --- a/app/src/main/java/com/android/quizcafe/core/data/repository/QuizBookSolvingRepositoryImpl.kt +++ b/app/src/main/java/com/android/quizcafe/core/data/repository/QuizBookSolvingRepositoryImpl.kt @@ -15,13 +15,13 @@ import com.android.quizcafe.core.database.dao.quiz.QuizDao import com.android.quizcafe.core.database.dao.quiz.QuizGradeDao import com.android.quizcafe.core.database.dao.quizBook.QuizBookDao import com.android.quizcafe.core.database.dao.quizBook.QuizBookGradeDao +import com.android.quizcafe.core.database.model.quizbook.QuizBookEntity import com.android.quizcafe.core.database.model.grading.QuizBookGradeEntity import com.android.quizcafe.core.database.model.grading.QuizGradeEntity -import com.android.quizcafe.core.database.model.quizbook.QuizBookEntity import com.android.quizcafe.core.domain.model.Resource import com.android.quizcafe.core.domain.model.quiz.QuizGrade -import com.android.quizcafe.core.domain.model.solving.QuizBookGrade import com.android.quizcafe.core.domain.model.solving.QuizBookSolving +import com.android.quizcafe.core.domain.model.solving.QuizBookGrade import com.android.quizcafe.core.domain.model.value.QuizBookGradeLocalId import com.android.quizcafe.core.domain.model.value.QuizBookGradeServerId import com.android.quizcafe.core.domain.model.value.QuizBookId @@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import javax.inject.Inject import kotlin.collections.map - class QuizBookSolvingRepositoryImpl @Inject constructor( private val quizGradeDao: QuizGradeDao, private val quizDao: QuizDao, @@ -49,15 +48,24 @@ class QuizBookSolvingRepositoryImpl @Inject constructor( * 퀴즈북 풀기 시작할 때 호출 * localId값 반환 */ - override fun createEmptyQuizBookGrade(id: QuizBookId): Flow> = flow { + override fun getOrCreateQuizBookGrade(quizBookId: QuizBookId): Flow> = flow { emit(Resource.Loading) - val entity = QuizBookGradeEntity(quizBookId = id.value) - val generatedId = quizBookGradeDao.upsertQuizBookGrade(entity) + val quizBookGradeRelation = quizBookGradeDao.getQuizBookGrade(quizBookId.value) - if (generatedId <= 0L) { - emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 실패", code = LocalErrorCode.ROOM_ERROR)) + if (quizBookGradeRelation != null) { + emit(Resource.Success(quizBookGradeRelation.toDomain())) } else { - emit(Resource.Success(QuizBookGradeLocalId(generatedId))) + val entity = QuizBookGradeEntity(quizBookId = quizBookId.value) + val quizBookGradeLocalId = quizBookGradeDao.upsertQuizBookGrade(entity) + + if (quizBookGradeLocalId <= 0L) { + emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 실패", code = LocalErrorCode.ROOM_ERROR)) + } else { + val relation = quizBookGradeDao.getQuizBookGradeByLocalId(quizBookGradeLocalId) + relation?.let { + emit(Resource.Success(it.toDomain())) + } ?: emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 후 조회 실패", code = LocalErrorCode.ROOM_ERROR)) + } } }.catch { e -> emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 중 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR)) @@ -68,7 +76,7 @@ class QuizBookSolvingRepositoryImpl @Inject constructor( val quizGradeRelation = quizGradeDao.getQuizGradeByQuizId(quizId.value, quizBookGradeLocalId.value) val quizGrade = quizGradeRelation?.toDomain() if (quizGrade == null) { - emit(Resource.Failure(errorMessage = "퀴즈 Grade 조회 실패", code = LocalErrorCode.ROOM_ERROR)) + emit(Resource.Failure(errorMessage = "getQuizGrade 중 quizGrade is null", code = LocalErrorCode.ROOM_ERROR)) } else { emit(Resource.Success(quizGrade)) } @@ -76,7 +84,6 @@ class QuizBookSolvingRepositoryImpl @Inject constructor( emit(Resource.Failure(errorMessage = "퀴즈 한문제씩 가져오다가 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR)) } - // 퀴즈북 풀이 기록 가져오기 override fun getQuizBookGrade(id: QuizBookGradeLocalId): Flow> = flow { emit(Resource.Loading) val quizBookGradeRelation = quizBookGradeDao.getQuizBookGrade(id.value) @@ -125,13 +132,25 @@ class QuizBookSolvingRepositoryImpl @Inject constructor( emit(Resource.Failure(errorMessage = "퀴즈 풀이 기록 저장 중 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR)) } + override fun deleteQuizBookGrade(id: QuizBookGradeLocalId): Flow> = flow { + emit(Resource.Loading) + val deletedCnt = quizBookGradeDao.deleteQuizBookGrade(id.value) + if (deletedCnt == 1) { + emit(Resource.Success(Unit)) + } else { + emit(Resource.Failure(errorMessage = "퀴즈북 풀이 기록 삭제 중 오류 : deletedCnt = $deletedCnt", code = LocalErrorCode.ROOM_ERROR)) + } + }.catch { e -> + emit(Resource.Failure(errorMessage = "퀴즈북 풀이 기록 삭제 중 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR)) + } + // 로컬에서 퀴즈북 풀이 기록 가져와 requestDto로 변환 후 퀴즈북 풀이 완료 API 요청하기 override fun solveQuizBook( - quizBookGradeLocalId: QuizBookGradeLocalId, + localId: QuizBookGradeLocalId, elapsedTimeInSeconds: Long ): Flow> = flow { emit(Resource.Loading) - val (quizBookGradeEntity, quizGradeEntities) = getQuizBookGradeData(quizBookGradeLocalId) + val (quizBookGradeEntity, quizGradeEntities) = getQuizBookGradeRelation(localId) val quizBookEntity = getQuizBookEntity(quizBookGradeEntity.quizBookId) val requestDto = createQuizBookSolvingRequest( @@ -144,7 +163,7 @@ class QuizBookSolvingRepositoryImpl @Inject constructor( quizBookSolvingRemoteDataSource.solveQuizBook(requestDto) .onSuccess { response -> response.data?.let { serverId -> - quizBookGradeDao.deleteQuizBookGrade(quizBookGradeLocalId.value) + quizBookGradeDao.deleteQuizBookGrade(localId.value) deleteQuizBookFromLocal( QuizBookId(quizBookGradeEntity.quizBookId) ) @@ -159,10 +178,10 @@ class QuizBookSolvingRepositoryImpl @Inject constructor( } // 로컬에서 퀴즈북 풀이 기록 가져오기 - null 체크 추가 - private suspend fun getQuizBookGradeData( + private suspend fun getQuizBookGradeRelation( localId: QuizBookGradeLocalId ): Pair> { - val quizBookGradeRelation = quizBookGradeDao.getQuizBookGrade(localId.value) + val quizBookGradeRelation = quizBookGradeDao.getQuizBookGradeByLocalId(localId.value) ?: throw IllegalStateException("QuizBookGrade not found for localId: ${localId.value}") return Pair( diff --git a/app/src/main/java/com/android/quizcafe/core/database/dao/quizBook/QuizBookGradeDao.kt b/app/src/main/java/com/android/quizcafe/core/database/dao/quizBook/QuizBookGradeDao.kt index efea1800..36481666 100644 --- a/app/src/main/java/com/android/quizcafe/core/database/dao/quizBook/QuizBookGradeDao.kt +++ b/app/src/main/java/com/android/quizcafe/core/database/dao/quizBook/QuizBookGradeDao.kt @@ -13,11 +13,14 @@ interface QuizBookGradeDao { @Upsert suspend fun upsertQuizBookGrade(entity: QuizBookGradeEntity): Long - // QuizBookGradeEntity LocalId로 QuizQuizBookSolvingResult 리스트 반환 @Transaction - @Query("SELECT * FROM QuizBookGradeEntity WHERE localId = :localId") - suspend fun getQuizBookGrade(localId: Long): QuizBookGradeWithQuizGradesRelation? + @Query("SELECT * FROM QuizBookGradeEntity WHERE localId = :quizBookGradeLocalId") + suspend fun getQuizBookGradeByLocalId(quizBookGradeLocalId: Long): QuizBookGradeWithQuizGradesRelation? + + @Transaction + @Query("SELECT * FROM QuizBookGradeEntity WHERE quizBookId = :quizBookId") + suspend fun getQuizBookGrade(quizBookId: Long): QuizBookGradeWithQuizGradesRelation? @Query("DELETE FROM QuizBookGradeEntity WHERE localId = :localId") - suspend fun deleteQuizBookGrade(localId: Long) + suspend fun deleteQuizBookGrade(localId: Long): Int } diff --git a/app/src/main/java/com/android/quizcafe/core/database/model/grading/QuizBookGradeEntity.kt b/app/src/main/java/com/android/quizcafe/core/database/model/grading/QuizBookGradeEntity.kt index bdcc5819..c37bb3b8 100644 --- a/app/src/main/java/com/android/quizcafe/core/database/model/grading/QuizBookGradeEntity.kt +++ b/app/src/main/java/com/android/quizcafe/core/database/model/grading/QuizBookGradeEntity.kt @@ -1,10 +1,18 @@ package com.android.quizcafe.core.database.model.grading import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import kotlin.time.Duration -@Entity +@Entity( + indices = [ + Index( + value = ["quizBookId"], + unique = true + ) + ] +) data class QuizBookGradeEntity( @PrimaryKey(autoGenerate = true) val localId: Long = 0, diff --git a/app/src/main/java/com/android/quizcafe/core/domain/model/quizbook/response/QuizBook.kt b/app/src/main/java/com/android/quizcafe/core/domain/model/quizbook/response/QuizBook.kt index 79327dd8..b26f3979 100644 --- a/app/src/main/java/com/android/quizcafe/core/domain/model/quizbook/response/QuizBook.kt +++ b/app/src/main/java/com/android/quizcafe/core/domain/model/quizbook/response/QuizBook.kt @@ -1,9 +1,10 @@ package com.android.quizcafe.core.domain.model.quizbook.response import com.android.quizcafe.core.domain.model.quiz.Quiz +import com.android.quizcafe.core.domain.model.value.QuizBookId data class QuizBook( - val id: Long, + val id: QuizBookId, val version: Long, val category: String, val title: String, diff --git a/app/src/main/java/com/android/quizcafe/core/domain/repository/QuizBookSolvingRepository.kt b/app/src/main/java/com/android/quizcafe/core/domain/repository/QuizBookSolvingRepository.kt index f6c156e0..7214f4c7 100644 --- a/app/src/main/java/com/android/quizcafe/core/domain/repository/QuizBookSolvingRepository.kt +++ b/app/src/main/java/com/android/quizcafe/core/domain/repository/QuizBookSolvingRepository.kt @@ -12,7 +12,10 @@ import kotlinx.coroutines.flow.Flow interface QuizBookSolvingRepository { - fun createEmptyQuizBookGrade(id: QuizBookId): Flow> + fun getOrCreateQuizBookGrade(id: QuizBookId): Flow> + + // 퀴즈북 풀이 로컬 기록 삭제하기 + fun deleteQuizBookGrade(id: QuizBookGradeLocalId): Flow> // 퀴즈 풀이 로컬 기록 가져오기 fun getQuizGrade(quizBookGradeLocalId: QuizBookGradeLocalId, quizId: QuizId): Flow> diff --git a/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/DeleteQuizBookGradeUseCase.kt b/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/DeleteQuizBookGradeUseCase.kt new file mode 100644 index 00000000..992177a5 --- /dev/null +++ b/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/DeleteQuizBookGradeUseCase.kt @@ -0,0 +1,14 @@ +package com.android.quizcafe.core.domain.usecase.solving + +import com.android.quizcafe.core.domain.model.Resource +import com.android.quizcafe.core.domain.model.value.QuizBookGradeLocalId +import com.android.quizcafe.core.domain.repository.QuizBookSolvingRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class DeleteQuizBookGradeUseCase @Inject constructor( + private val quizBookSolvingRepository: QuizBookSolvingRepository +) { + operator fun invoke(quizBookGradeLocalId: QuizBookGradeLocalId): Flow> = + quizBookSolvingRepository.deleteQuizBookGrade(quizBookGradeLocalId) +} diff --git a/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetOrCreateQuizBookGradeUseCase.kt b/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetOrCreateQuizBookGradeUseCase.kt new file mode 100644 index 00000000..003cf02a --- /dev/null +++ b/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetOrCreateQuizBookGradeUseCase.kt @@ -0,0 +1,15 @@ +package com.android.quizcafe.core.domain.usecase.solving + +import com.android.quizcafe.core.domain.model.Resource +import com.android.quizcafe.core.domain.model.solving.QuizBookGrade +import com.android.quizcafe.core.domain.model.value.QuizBookId +import com.android.quizcafe.core.domain.repository.QuizBookSolvingRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetOrCreateQuizBookGradeUseCase @Inject constructor( + private val quizBookSolvingRepository: QuizBookSolvingRepository +) { + operator fun invoke(quizBookId: QuizBookId): Flow> = + quizBookSolvingRepository.getOrCreateQuizBookGrade(quizBookId) +} diff --git a/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetQuizBookGradeUseCase.kt b/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetQuizBookGradeUseCase.kt deleted file mode 100644 index 75416f63..00000000 --- a/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetQuizBookGradeUseCase.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.android.quizcafe.core.domain.usecase.solving - -import com.android.quizcafe.core.domain.model.value.QuizBookGradeLocalId -import com.android.quizcafe.core.domain.repository.QuizBookSolvingRepository -import javax.inject.Inject - -/** - * 현재 QuizBookGrade에 있는 모든 QuizGrade 가져오기 - */ -class GetQuizBookGradeUseCase @Inject constructor( - private val quizBookSolvingRepository: QuizBookSolvingRepository -) { - operator fun invoke(id: QuizBookGradeLocalId) = - quizBookSolvingRepository.getQuizBookGrade(id) -} diff --git a/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetQuizBookLocalIdUseCase.kt b/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetQuizBookLocalIdUseCase.kt deleted file mode 100644 index f27717f9..00000000 --- a/app/src/main/java/com/android/quizcafe/core/domain/usecase/solving/GetQuizBookLocalIdUseCase.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.android.quizcafe.core.domain.usecase.solving - -import com.android.quizcafe.core.domain.model.value.QuizBookId -import com.android.quizcafe.core.domain.repository.QuizBookSolvingRepository -import javax.inject.Inject - -class GetQuizBookLocalIdUseCase @Inject constructor( - private val quizBookSolvingRepository: QuizBookSolvingRepository -) { - operator fun invoke(id: QuizBookId) = - quizBookSolvingRepository.createEmptyQuizBookGrade(id) -} diff --git a/app/src/main/java/com/android/quizcafe/core/ui/util/TestTags.kt b/app/src/main/java/com/android/quizcafe/core/ui/util/TestTags.kt new file mode 100644 index 00000000..69c8e0aa --- /dev/null +++ b/app/src/main/java/com/android/quizcafe/core/ui/util/TestTags.kt @@ -0,0 +1,13 @@ +package com.android.quizcafe.core.ui.util + +object TestTags { + + object QuizSolve { + const val EXIT_DIALOG = "exit_dialog" + const val EXIT_DIALOG_CLOSE_BUTTON = "exit_dialog_close_button" + const val EXIT_DIALOG_TITLE = "exit_dialog_title" + const val EXIT_DIALOG_DESCRIPTION = "exit_dialog_description" + const val EXIT_DIALOG_EXIT_DELETE_BUTTON = "exit_dialog_exit_delete_button" + const val EXIT_DIALOG_EXIT_SAVE_BUTTON = "exit_dialog_exit_save_button" + } +} diff --git a/app/src/main/java/com/android/quizcafe/feature/main/MainScreen.kt b/app/src/main/java/com/android/quizcafe/feature/main/MainScreen.kt index ca89c859..ed5ad170 100644 --- a/app/src/main/java/com/android/quizcafe/feature/main/MainScreen.kt +++ b/app/src/main/java/com/android/quizcafe/feature/main/MainScreen.kt @@ -119,7 +119,9 @@ fun MainBottomNavHost( QuizBookListRoute( category = category, - navigateToQuizBookDetail = { quizBookId -> bottomNavController.navigateSingleTopTo("${MainRoute.QuizBookDetail.route}/$quizBookId") }, + navigateToQuizBookDetail = { quizBookId -> + bottomNavController.navigateSingleTopTo("${MainRoute.QuizBookDetail.route}/${quizBookId.value}") + }, navigateToCategory = {}, ) } diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveRoute.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveRoute.kt index 8a2cba24..aa217aa5 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveRoute.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveRoute.kt @@ -1,14 +1,22 @@ package com.android.quizcafe.feature.quiz.solve import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.viewmodel.compose.viewModel import com.android.quizcafe.R import com.android.quizcafe.core.domain.model.value.QuizBookGradeServerId +import com.android.quizcafe.feature.quiz.solve.component.ExitSolvingDialog +import com.android.quizcafe.feature.quiz.solve.component.ResumeSolvingDialog +import com.android.quizcafe.feature.quiz.solve.viewmodel.IQuizSolveViewModel import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveEffect import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveIntent import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveViewModel @@ -18,18 +26,28 @@ fun QuizSolveRoute( quizBookId: Long, navigateToBack: () -> Unit, navigateToQuizBookSolvingResult: (QuizBookGradeServerId) -> Unit, - viewModel: QuizSolveViewModel = hiltViewModel() + viewModel: IQuizSolveViewModel = hiltViewModel() ) { val context = LocalContext.current val state by viewModel.state.collectAsState() + var showExitDialog by remember { mutableStateOf(false) } + var showResumeDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - viewModel.sendIntent(QuizSolveIntent.Initialize(quizBookId)) - } - LaunchedEffect(Unit) { + viewModel.sendIntent(QuizSolveIntent.StartSolving(quizBookId)) viewModel.effect.collect { effect -> when (effect) { - QuizSolveEffect.NavigatePopBack -> { + QuizSolveEffect.ShowResumeDialog -> { + showResumeDialog = true + } + QuizSolveEffect.CloseResumeDialog -> { + showResumeDialog = false + } + QuizSolveEffect.ShowExitDialog -> { + showExitDialog = true + } + QuizSolveEffect.NavigateToBack -> { + if (showExitDialog) showExitDialog = false navigateToBack() } is QuizSolveEffect.NavigateToQuizBookSolvingResult -> { @@ -47,6 +65,35 @@ fun QuizSolveRoute( } } + BackHandler(enabled = !showExitDialog) { + viewModel.sendIntent(QuizSolveIntent.NavigateBack) + } + + if (showResumeDialog) { + ResumeSolvingDialog( + onResume = { + viewModel.sendIntent(QuizSolveIntent.ResumeSolving(resumeWithNewSolving = false)) + }, + onStartNew = { + viewModel.sendIntent(QuizSolveIntent.ResumeSolving(resumeWithNewSolving = true)) + } + ) + } + + if (showExitDialog) { + ExitSolvingDialog( + onDismissRequest = { + showExitDialog = false + }, + onExitWithDelete = { + viewModel.sendIntent(QuizSolveIntent.ExitWithDelete) + }, + onExitWithSave = { + viewModel.sendIntent(QuizSolveIntent.ExitWithSave) + }, + ) + } + QuizSolveScreen( uiState = state, onIntent = viewModel::sendIntent diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveScreen.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveScreen.kt index c7f6211f..a7895f6f 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveScreen.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/QuizSolveScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/component/ExitSolvingDialog.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/component/ExitSolvingDialog.kt new file mode 100644 index 00000000..31f9882a --- /dev/null +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/component/ExitSolvingDialog.kt @@ -0,0 +1,124 @@ +package com.android.quizcafe.feature.quiz.solve.component + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.android.quizcafe.R +import com.android.quizcafe.core.designsystem.theme.QuizCafeTheme +import com.android.quizcafe.core.ui.util.TestTags + +@Composable +fun ExitSolvingDialog( + modifier: Modifier = Modifier, + onDismissRequest: () -> Unit, + onExitWithDelete: () -> Unit, + onExitWithSave: () -> Unit +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnClickOutside = false + ) + ) { + Card( + modifier = modifier + .fillMaxWidth() + .testTag(TestTags.QuizSolve.EXIT_DIALOG), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ) + ) { + Box(contentAlignment = Alignment.TopEnd) { + IconButton( + modifier = Modifier.testTag(TestTags.QuizSolve.EXIT_DIALOG_CLOSE_BUTTON), + onClick = onDismissRequest + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dialog_close) + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 32.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.dialog_exit_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag(TestTags.QuizSolve.EXIT_DIALOG_TITLE) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.dialog_exit_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.testTag(TestTags.QuizSolve.EXIT_DIALOG_DESCRIPTION) + ) + + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onExitWithDelete, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier.testTag(TestTags.QuizSolve.EXIT_DIALOG_EXIT_DELETE_BUTTON) + ) { + Text(text = stringResource(R.string.dialog_exit_with_delete)) + } + + Spacer(modifier = Modifier.width(8.dp)) + + TextButton( + onClick = onExitWithSave, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.testTag(TestTags.QuizSolve.EXIT_DIALOG_EXIT_SAVE_BUTTON) + ) { + Text(text = stringResource(R.string.dialog_exit_with_save)) + } + } + } + } + } + } +} + +@Preview(name = "ExitSolvingDialog", showBackground = true) +@Composable +fun ExitSolvingDialogPreview() { + QuizCafeTheme { + ExitSolvingDialog( + onDismissRequest = {}, + onExitWithDelete = {}, + onExitWithSave = {} + ) + } +} diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/component/ResumeSolvingDialog.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/component/ResumeSolvingDialog.kt new file mode 100644 index 00000000..4bb1a018 --- /dev/null +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/component/ResumeSolvingDialog.kt @@ -0,0 +1,110 @@ +package com.android.quizcafe.feature.quiz.solve.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.android.quizcafe.R +import com.android.quizcafe.core.designsystem.theme.QuizCafeTheme + +@Composable +fun ResumeSolvingDialog( + onResume: () -> Unit, + onStartNew: () -> Unit +) { + Dialog( + onDismissRequest = { }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { + Card( + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 32.dp, bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.dialog_resume_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.dialog_resume_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onStartNew, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text(text = stringResource(R.string.dialog_start_new_solving)) + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton( + onClick = onResume, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Text(text = stringResource(R.string.dialog_resume_solving)) + } + } + } + } + } +} + +@Preview(name = "ResumeSolvingDialog", showBackground = true) +@Composable +fun ResumeSolvingDialogPreview() { + QuizCafeTheme { + ResumeSolvingDialog( + onResume = {}, + onStartNew = {} + ) + } +} diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/IQuizSolveViewModel.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/IQuizSolveViewModel.kt new file mode 100644 index 00000000..6333a3e0 --- /dev/null +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/IQuizSolveViewModel.kt @@ -0,0 +1,10 @@ +package com.android.quizcafe.feature.quiz.solve.viewmodel + +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +interface IQuizSolveViewModel { + val state: StateFlow + val effect: SharedFlow + fun sendIntent(intent: QuizSolveIntent) +} diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveEffect.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveEffect.kt index 8efd682a..a33affe2 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveEffect.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveEffect.kt @@ -5,6 +5,9 @@ import com.android.quizcafe.core.ui.base.BaseContract sealed class QuizSolveEffect : BaseContract.ViewEffect { data class ShowErrorDialog(val message: String) : QuizSolveEffect() - data object NavigatePopBack : QuizSolveEffect() + data object ShowExitDialog : QuizSolveEffect() + data object ShowResumeDialog : QuizSolveEffect() + data object CloseResumeDialog : QuizSolveEffect() + data object NavigateToBack : QuizSolveEffect() data class NavigateToQuizBookSolvingResult(val quizBookGradeServerId: QuizBookGradeServerId) : QuizSolveEffect() } diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveIntent.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveIntent.kt index 8ca19be9..f2e6c9f7 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveIntent.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveIntent.kt @@ -3,16 +3,17 @@ package com.android.quizcafe.feature.quiz.solve.viewmodel import com.android.quizcafe.core.domain.model.quiz.QuizGrade import com.android.quizcafe.core.domain.model.quizbook.response.QuizBook import com.android.quizcafe.core.domain.model.solving.QuizBookGrade -import com.android.quizcafe.core.domain.model.value.QuizBookGradeLocalId import com.android.quizcafe.core.domain.model.value.QuizBookGradeServerId import com.android.quizcafe.core.ui.base.BaseContract sealed class QuizSolveIntent : BaseContract.ViewIntent { // 초기화 작업 - data class Initialize(val quizBookId: Long) : QuizSolveIntent() + data class StartSolving(val quizBookId: Long) : QuizSolveIntent() data class LoadQuizBookSuccess(val quizBook: QuizBook) : QuizSolveIntent() data class LoadQuizBookGradeSuccess(val quizBookGrade: QuizBookGrade) : QuizSolveIntent() - data class SetQuizBookLocalId(val quizBookLocalId: QuizBookGradeLocalId) : QuizSolveIntent() + + data class ResumeSolving(val resumeWithNewSolving: Boolean) : QuizSolveIntent() + data object StartTimer : QuizSolveIntent() // 문제 선택 data class SelectAnswer(val option: QuizOption) : QuizSolveIntent() @@ -30,6 +31,7 @@ sealed class QuizSolveIntent : BaseContract.ViewIntent { data class SubmitQuizBookSuccess(val quizBookGradeServerId: QuizBookGradeServerId) : QuizSolveIntent() data class HandleError(val message: String?) : QuizSolveIntent() - // 타이머 data object UpdateTimer : QuizSolveIntent() + data object ExitWithDelete : QuizSolveIntent() + data object ExitWithSave : QuizSolveIntent() } diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveUiState.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveUiState.kt index 9dc08505..9aff8a0e 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveUiState.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveUiState.kt @@ -4,7 +4,7 @@ import android.util.Log import com.android.quizcafe.core.domain.model.quiz.Quiz import com.android.quizcafe.core.domain.model.quiz.QuizGrade import com.android.quizcafe.core.domain.model.quizbook.response.QuizBook -import com.android.quizcafe.core.domain.model.value.QuizBookGradeLocalId +import com.android.quizcafe.core.domain.model.solving.QuizBookGrade import com.android.quizcafe.core.ui.base.BaseContract import com.android.quizcafe.feature.quiz.solve.component.AnswerState import java.util.Locale @@ -47,15 +47,15 @@ data class TimerState( data class CommonState( val playMode: PlayMode = PlayMode.DEFAULT, + val isTimerActive: Boolean = true, val currentIndex: Int = 0 ) data class QuizSolveUiState( val isLoading: Boolean = false, val errorMessage: String? = null, - val quizBookLocalId: QuizBookGradeLocalId? = null, val quizBook: QuizBook? = null, - val quizGrades: List? = null, + val quizBookGrade: QuizBookGrade? = null, val subjective: SubjectiveState = SubjectiveState(), val mcq: McqState = McqState(), val review: ReviewState = ReviewState(), diff --git a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveViewModel.kt b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveViewModel.kt index 3c277aa6..32c5f715 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveViewModel.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quiz/solve/viewmodel/QuizSolveViewModel.kt @@ -3,15 +3,15 @@ package com.android.quizcafe.feature.quiz.solve.viewmodel import android.util.Log import androidx.lifecycle.viewModelScope import com.android.quizcafe.core.domain.model.Resource -import com.android.quizcafe.core.domain.model.value.QuizBookGradeLocalId import com.android.quizcafe.core.domain.model.value.QuizBookId import com.android.quizcafe.core.domain.usecase.quizbook.GetQuizBookUseCase -import com.android.quizcafe.core.domain.usecase.solving.GetQuizBookGradeUseCase -import com.android.quizcafe.core.domain.usecase.solving.GetQuizBookLocalIdUseCase import com.android.quizcafe.core.domain.usecase.solving.GetQuizGradeUseCase +import com.android.quizcafe.core.domain.usecase.solving.DeleteQuizBookGradeUseCase +import com.android.quizcafe.core.domain.usecase.solving.GetOrCreateQuizBookGradeUseCase import com.android.quizcafe.core.domain.usecase.solving.GradeQuizUseCase import com.android.quizcafe.core.domain.usecase.solving.SolveQuizBookUseCase import com.android.quizcafe.core.ui.base.BaseViewModel +import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveEffect.* import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -21,62 +21,65 @@ import kotlin.math.abs @HiltViewModel class QuizSolveViewModel @Inject constructor( private val getQuizBookUseCase: GetQuizBookUseCase, - private val getQuizBookLocalIdUseCase: GetQuizBookLocalIdUseCase, - private val getQuizBookGradeUseCase: GetQuizBookGradeUseCase, + private val getOrCreateQuizBookGradeUseCase: GetOrCreateQuizBookGradeUseCase, private val gradeQuizUseCase: GradeQuizUseCase, private val solveQuizBookUseCase: SolveQuizBookUseCase, + private val deleteQuizBookGradeUseCase: DeleteQuizBookGradeUseCase, private val getQuizGradeUseCase: GetQuizGradeUseCase ) : BaseViewModel( initialState = QuizSolveUiState() -) { +), + IQuizSolveViewModel { init { viewModelScope.launch { while (true) { delay(1_000L) - sendIntent(QuizSolveIntent.UpdateTimer) + if (state.value.common.isTimerActive) { + sendIntent(QuizSolveIntent.UpdateTimer) + } } } } override suspend fun handleIntent(intent: QuizSolveIntent) { when (intent) { - QuizSolveIntent.NavigateBack -> { - emitEffect(QuizSolveEffect.NavigatePopBack) - } - - QuizSolveIntent.NavigateToResult -> { - // This will be handled by SubmitQuizBookSuccess - } - - // 초기화 - is QuizSolveIntent.Initialize -> { - loadQuizBook(abs(intent.quizBookId)) - getQuizBookLocalId(intent.quizBookId) + is QuizSolveIntent.StartSolving -> { + val quizBookId = QuizBookId(abs(intent.quizBookId)) + getQuizBook(quizBookId) + getOrCreateQuizBookGrade(quizBookId) } - is QuizSolveIntent.SetQuizBookLocalId -> { - getQuizBookGradeResult(intent.quizBookLocalId) + is QuizSolveIntent.ResumeSolving -> { + if (intent.resumeWithNewSolving) { + val quizBookId = state.value.quizBook?.id + deleteQuizBookGrade() + quizBookId?.let { + getOrCreateQuizBookGrade(it) + } + } + emitEffect(QuizSolveEffect.CloseResumeDialog) + sendIntent(QuizSolveIntent.StartTimer) } is QuizSolveIntent.SubmitQuizBookSuccess -> { - emitEffect(QuizSolveEffect.NavigateToQuizBookSolvingResult(intent.quizBookGradeServerId)) + emitEffect(NavigateToQuizBookSolvingResult(intent.quizBookGradeServerId)) } is QuizSolveIntent.HandleError -> { - emitEffect(QuizSolveEffect.ShowErrorDialog(intent.message ?: "")) + emitEffect(ShowErrorDialog(intent.message ?: "")) } QuizSolveIntent.NavigateToNextQuestion -> { val currentState = state.value + Log.d("test", "currentState : $currentState") when { currentState.common.playMode == PlayMode.REVIEW_MODE && !currentState.review.showExplanation -> { saveQuizToLocal() } - currentState.common.playMode == PlayMode.REVIEW_MODE && currentState.isLastQuestion -> { submitQuizAnswer() } - + // 리뷰모드 아닐 때 else -> { saveQuizToLocal() if (currentState.isLastQuestion) { @@ -89,37 +92,58 @@ class QuizSolveViewModel @Inject constructor( QuizSolveIntent.NavigateToPreviousQuestion -> { getQuizAnswer() } - + QuizSolveIntent.NavigateToResult -> { + // This will be handled by SubmitQuizBookSuccess + } + QuizSolveIntent.NavigateBack -> { + emitEffect(QuizSolveEffect.ShowExitDialog) + } is QuizSolveIntent.GradeQuizSuccess -> { val currentState = state.value when { currentState.common.playMode == PlayMode.REVIEW_MODE -> { getQuizAnswer() } - !currentState.isLastQuestion && currentState.common.playMode == PlayMode.DEFAULT -> { getQuizAnswer() } - else -> Unit } } + is QuizSolveIntent.ExitWithDelete -> { + deleteQuizBookGrade() + emitEffect(QuizSolveEffect.NavigateToBack) + } + is QuizSolveIntent.ExitWithSave -> { + emitEffect(QuizSolveEffect.NavigateToBack) + } is QuizSolveIntent.SelectAnswer, is QuizSolveIntent.UpdateSubjectiveAnswer, is QuizSolveIntent.LoadQuizBookSuccess, is QuizSolveIntent.LoadQuizBookGradeSuccess, - QuizSolveIntent.UpdateTimer -> { - } - - is QuizSolveIntent.GetQuizGradeSuccess -> { - } + is QuizSolveIntent.GetQuizGradeSuccess, + is QuizSolveIntent.StartTimer, + is QuizSolveIntent.UpdateTimer -> Unit } } override fun reduce(currentState: QuizSolveUiState, intent: QuizSolveIntent): QuizSolveUiState { return when (intent) { - // 타이머 + is QuizSolveIntent.StartSolving -> { + if (intent.quizBookId < 0) { + currentState.copy( + common = currentState.common.copy( + playMode = PlayMode.REVIEW_MODE + ), + isLoading = true, + errorMessage = null + ) + } else { + currentState.copy(isLoading = true, errorMessage = null) + } + } + QuizSolveIntent.UpdateTimer -> { val timer = currentState.timer currentState.copy( @@ -134,7 +158,7 @@ class QuizSolveViewModel @Inject constructor( mcq = currentState.mcq.copy( selectedId = intent.option.id, selectedContent = intent.option.text - ) + ), ) is QuizSolveIntent.UpdateSubjectiveAnswer -> @@ -144,20 +168,6 @@ class QuizSolveViewModel @Inject constructor( ) ) - is QuizSolveIntent.Initialize -> { - if (intent.quizBookId < 0) { - currentState.copy( - common = currentState.common.copy( - playMode = PlayMode.REVIEW_MODE - ), - isLoading = true, - errorMessage = null - ) - } else { - currentState.copy(isLoading = true, errorMessage = null) - } - } - is QuizSolveIntent.LoadQuizBookSuccess -> { currentState.copy( quizBook = intent.quizBook, @@ -165,15 +175,17 @@ class QuizSolveViewModel @Inject constructor( ) } - is QuizSolveIntent.SetQuizBookLocalId -> - currentState.copy( - quizBookLocalId = intent.quizBookLocalId - ) - - is QuizSolveIntent.LoadQuizBookGradeSuccess -> + is QuizSolveIntent.LoadQuizBookGradeSuccess -> { + val index = intent.quizBookGrade.quizGrades.size.let { + if (it == currentState.quizBook?.quizList?.size) it - 1 else it + } currentState.copy( - quizGrades = intent.quizBookGrade.quizGrades + quizBookGrade = intent.quizBookGrade, + common = currentState.common.copy( + currentIndex = index + ) ) + } QuizSolveIntent.NavigateToPreviousQuestion -> { if (currentState.common.currentIndex > 0) { @@ -189,83 +201,44 @@ class QuizSolveViewModel @Inject constructor( QuizSolveIntent.GradeQuizSuccess -> { currentState.onLocalSaveSuccess() } + QuizSolveIntent.StartTimer -> { + currentState.copy( + common = currentState.common.copy(isTimerActive = true) + ) + } is QuizSolveIntent.HandleError, QuizSolveIntent.NavigateBack, QuizSolveIntent.NavigateToNextQuestion, QuizSolveIntent.NavigateToResult, - is QuizSolveIntent.SubmitQuizBookSuccess -> currentState - } - } - - // MARK: - Private Methods - private suspend fun loadQuizBook(id: Long) { - getQuizBookUseCase( - QuizBookId(id) - ).collect { - when (it) { - is Resource.Success -> { - Log.d("getQuizBook", "${it.data.quizList}") - sendIntent(QuizSolveIntent.LoadQuizBookSuccess(it.data)) - } - - is Resource.Loading -> { - Log.d("getQuizBook", "Loading") - } - - is Resource.Failure -> { - Log.d("getQuizBook", it.errorMessage) - } - } - } - } - - private suspend fun getQuizBookLocalId(id: Long) { - getQuizBookLocalIdUseCase( - QuizBookId(abs(id)) - ).collect { - when (it) { - is Resource.Success -> { - Log.d("getQuizBookLocalId", "Get QuizBookDetail Success") - sendIntent(QuizSolveIntent.SetQuizBookLocalId(it.data)) - } - - is Resource.Loading -> { - Log.d("getQuizBookLocalId", "Loading") - } - - is Resource.Failure -> { - Log.d("getQuizBookLocalId", it.errorMessage) - } - } + is QuizSolveIntent.SubmitQuizBookSuccess, + is QuizSolveIntent.ExitWithSave, + is QuizSolveIntent.ResumeSolving, + is QuizSolveIntent.ExitWithDelete -> currentState } } - private suspend fun getQuizBookGradeResult(id: QuizBookGradeLocalId) { - getQuizBookGradeUseCase( - id - ).collect { - when (it) { - is Resource.Success -> { - Log.d("getQuizBookGradeUseCase", "Get QuizBookDetail Success") - sendIntent(QuizSolveIntent.LoadQuizBookGradeSuccess(it.data)) - } - - is Resource.Loading -> { - Log.d("getQuizBookGradeUseCase", "Loading") - } - - is Resource.Failure -> { - Log.d("getQuizBookGradeUseCase", it.errorMessage) + private suspend fun deleteQuizBookGrade() { + val quizBookLocalId = state.value.quizBookGrade?.localId + quizBookLocalId?.let { + deleteQuizBookGradeUseCase(it).collect { + when (it) { + is Resource.Success -> { + Log.d("deleteQuizBookGradeUseCase", "Get QuizBookDetail Success") + } + is Resource.Failure -> { + Log.d("deleteQuizBookGradeUseCase", it.errorMessage) + } + else -> Unit } } - } + } ?: Log.d("deleteQuizBookGrade", "quizBookLocalId is null") } private suspend fun saveQuizToLocal() { val uiState = state.value val quiz = uiState.currentQuiz - val localId = uiState.quizBookLocalId + val localId = uiState.quizBookGrade?.localId val userAnswer = when (uiState.questionType) { QuestionType.SUBJECTIVE -> uiState.subjective.answer else -> uiState.mcq.selectedContent @@ -294,12 +267,13 @@ class QuizSolveViewModel @Inject constructor( } } } else { + Log.d("saveQuizToLocal", "문제 발생") sendIntent(QuizSolveIntent.HandleError("문제 발생")) } } private suspend fun submitQuizAnswer() { - val localId = state.value.quizBookLocalId + val localId = state.value.quizBookGrade?.localId val elapsedTimeInSeconds: Long = state.value.timer.elapsedSeconds.toLong() if (localId != null) { solveQuizBookUseCase( @@ -329,7 +303,7 @@ class QuizSolveViewModel @Inject constructor( private suspend fun getQuizAnswer() { val currentState = state.value - val localId = currentState.quizBookLocalId + val localId = state.value.quizBookGrade?.localId val quizId = currentState.currentQuiz?.id if (localId != null && quizId != null) { getQuizGradeUseCase( @@ -356,4 +330,54 @@ class QuizSolveViewModel @Inject constructor( sendIntent(QuizSolveIntent.HandleError("문제 발생")) } } + + private suspend fun getQuizBook(quizBookId: QuizBookId) { + getQuizBookUseCase( + quizBookId + ).collect { + when (it) { + is Resource.Success -> { + Log.d("getQuizBook", "${it.data.quizList}") + sendIntent(QuizSolveIntent.LoadQuizBookSuccess(it.data)) + } + + is Resource.Loading -> { + Log.d("getQuizBook", "Loading") + } + + is Resource.Failure -> { + Log.d("getQuizBook", it.errorMessage) + } + } + } + } + + // 여기서 QuizGrade 정보들이 있냐없냐로 ResumeDialog 띄울지 말지 결정 + private suspend fun getOrCreateQuizBookGrade( + quizBookId: QuizBookId, + ) { + getOrCreateQuizBookGradeUseCase(quizBookId = quizBookId).collect { + when (it) { + is Resource.Success -> { + Log.d("getQuizBookGradeUseCase", "Get QuizBookDetail Success") + Log.d("getOrCreateQuizBookGradeUseCase", "quizBookGrade : ${it.data}") + val isResume = it.data.quizGrades.isNotEmpty() + if (isResume) { + emitEffect(QuizSolveEffect.ShowResumeDialog) + } else { + sendIntent(QuizSolveIntent.StartTimer) + } + sendIntent(QuizSolveIntent.LoadQuizBookGradeSuccess(it.data)) + } + + is Resource.Loading -> { + Log.d("getQuizBookGradeUseCase", "Loading") + } + + is Resource.Failure -> { + Log.d("getQuizBookGradeUseCase", it.errorMessage) + } + } + } + } } diff --git a/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContent.kt b/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContent.kt index 09b5c9db..e726908d 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContent.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContent.kt @@ -32,11 +32,12 @@ import com.android.quizcafe.core.designsystem.theme.onPrimaryLight import com.android.quizcafe.core.designsystem.theme.outlineLight import com.android.quizcafe.core.designsystem.theme.quizCafeTypography import com.android.quizcafe.core.domain.model.quizbook.response.QuizBook +import com.android.quizcafe.core.domain.model.value.QuizBookId @Composable fun QuizBookCardList( quizBooks: List, - onQuizBookClick: (Long) -> Unit + onQuizBookClick: (QuizBookId) -> Unit ) { LazyColumn { items(quizBooks) { quizBook -> @@ -48,7 +49,7 @@ fun QuizBookCardList( @Composable fun QuizBookCard( quizBook: QuizBook, - onQuizBookClick: (Long) -> Unit + onQuizBookClick: (QuizBookId) -> Unit ) { Spacer(Modifier.height(8.dp)) Card( @@ -181,7 +182,7 @@ fun QuizBookFilterButton( fun QuizBookCardListPreview() { val sampleQuizBooks = listOf( QuizBook( - id = 1L, + id = QuizBookId(1L), ownerName = "시스템 관리자", category = "운영체제", title = "모두의 운영체제", @@ -194,7 +195,7 @@ fun QuizBookCardListPreview() { level = TODO() ), QuizBook( - id = 2L, + id = QuizBookId(2L), ownerName = "싸피_박성준", category = "운영체제", title = "성준이의 운영체제", diff --git a/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContract.kt b/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContract.kt index 85a0c251..75c40827 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContract.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListContract.kt @@ -1,6 +1,7 @@ package com.android.quizcafe.feature.quizbooklist import com.android.quizcafe.core.domain.model.quizbook.response.QuizBook +import com.android.quizcafe.core.domain.model.value.QuizBookId import com.android.quizcafe.core.ui.base.BaseContract data class QuizBookListUiState( @@ -14,7 +15,7 @@ data class QuizBookListUiState( sealed class QuizBookListIntent : BaseContract.ViewIntent { data object LoadQuizBooks : QuizBookListIntent() - data class ClickQuizBook(val quizBookId: Long) : QuizBookListIntent() + data class ClickQuizBook(val quizBookId: QuizBookId) : QuizBookListIntent() data class UpdateCategory(val category: String) : QuizBookListIntent() data class UpdateFilterOptions(val filterState: FilterState) : QuizBookListIntent() @@ -26,5 +27,5 @@ sealed class QuizBookListIntent : BaseContract.ViewIntent { sealed class QuizBookListEffect : BaseContract.ViewEffect { data class ShowError(val message: String) : QuizBookListEffect() data object NavigateToCategory : QuizBookListEffect() - data class NavigateToQuizBookDetail(val quizBookId: Long) : QuizBookListEffect() + data class NavigateToQuizBookDetail(val quizBookId: QuizBookId) : QuizBookListEffect() } diff --git a/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListRoute.kt b/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListRoute.kt index df0def5b..a54da96c 100644 --- a/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListRoute.kt +++ b/app/src/main/java/com/android/quizcafe/feature/quizbooklist/QuizBookListRoute.kt @@ -8,11 +8,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import com.android.quizcafe.R +import com.android.quizcafe.core.domain.model.value.QuizBookId @Composable fun QuizBookListRoute( category: String, - navigateToQuizBookDetail: (Long) -> Unit, + navigateToQuizBookDetail: (QuizBookId) -> Unit, navigateToCategory: () -> Unit, viewModel: QuizBookListViewModel = hiltViewModel() ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4df09cc2..47820975 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -139,4 +139,20 @@ 비밀번호 변경 취소 + + 닫기 + 기록을 저장하시겠습니까? + + 기록을 저장하지 않고 종료하면 현재까지의 풀이 기록은 삭제됩니다. + + 이전에 풀던 기록이 있어요 + + 이어서 푸시겠어요, 아니면 새로 시작하시겠어요? + + + 삭제 + 저장 + 이어서 풀기" + 새로 풀기 + \ No newline at end of file