Conversation
Walkthrough메모리 퀴즈 기능을 완전히 구현합니다. STT(음성 인식) 통합, UI 컴포넌트, 도메인/데이터 모델, 뷰모델 및 네비게이션을 추가하고, 기존 퀴즈 카테고리 UI를 단순화합니다. Changes
Sequence DiagramsequenceDiagram
actor User
participant Screen as MemoryQuizScreen
participant VM as MemoryQuizViewModel
participant STT as AndroidSttManager
participant UseCase as FetchQuizUseCase
participant Backend as Backend API
User->>Screen: Screen Load
activate Screen
Screen->>VM: Observes uiState
VM->>VM: Init: Load quizzes
VM->>UseCase: fetchMemoryQuizzes()
UseCase->>Backend: GET /quizzes?category=MEMORY
Backend-->>UseCase: MemoryQuizResponse[]
UseCase-->>VM: MemoryQuiz[]
VM->>VM: Set uiState.quizzes, isLoading=false
VM-->>Screen: uiState updated
Screen->>Screen: Render quiz
deactivate Screen
User->>Screen: Tap Start Quiz
Screen->>VM: onStartQuiz()
VM->>VM: Set quizState=QUESTION_DISPLAY
VM-->>Screen: uiState updated
Screen->>Screen: Display images
User->>Screen: Tap Microphone
Screen->>VM: onStartSpeakingClick()
VM->>STT: startListening()
activate STT
STT->>STT: Initialize SpeechRecognizer
STT->>STT: Start audio capture
STT-->>VM: sttState=Speaking
VM-->>Screen: uiState.isSpeaking=true
Screen->>Screen: Animate voice button
User->>User: Speak answer
STT->>STT: onResults: Capture speech
STT-->>VM: sttState=Success(result)
deactivate STT
VM->>VM: checkAnswer(result)
VM->>VM: Compare with answer list<br/>(whitespace normalization)
VM->>VM: Update correctCount, showResultDialog
VM-->>Screen: uiState updated
Screen->>Screen: Show result dialog
User->>Screen: Tap Continue
Screen->>VM: onNextQuestion()
VM->>VM: currentQuestionIndex++
alt Quiz Complete
VM->>UseCase: uploadQuizScore()
UseCase->>Backend: POST /scores
Backend-->>UseCase: Success
VM->>VM: Navigate back
else More Questions
VM->>VM: Set quizState=QUESTION_DISPLAY
VM-->>Screen: uiState updated
end
Screen->>Screen: Update UI
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60분 Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (11)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizSlideAnimation.kt (1)
45-76:contentKey기본값의 nullable 타입 안전성 문제
contentKey: (T) -> Any = { it as Any }기본값은T가 nullable 타입일 때null값에서 NPE를 발생시킬 수 있습니다.🔎 제안된 수정
@Composable fun <T> CommonSideAnimation( targetState: T, modifier: Modifier = Modifier, - contentKey: (T) -> Any = { it as Any }, + contentKey: (T) -> Any? = { it }, content: @Composable (T) -> Unit, ) {feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizPlayContent.kt (1)
75-85: AsyncImage 로딩/에러 상태 처리 고려네트워크 이미지 로딩 실패 시 사용자에게 피드백이 없습니다.
placeholder와error파라미터를 추가하여 로딩 및 에러 상태를 처리하는 것을 고려해 주세요.feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeViewModel.kt (1)
10-14: 사용되지 않는 import 제거를 권장합니다.
MutableSharedFlow,SharedFlow,asSharedFlow가 import되어 있지만 side-effect 패턴 제거 후 더 이상 사용되지 않습니다.🔎 제안된 수정
import com.moa.app.navigation.Navigator import dagger.hilt.android.lifecycle.HiltViewModel -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 import kotlinx.coroutines.flow.updatecore/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/textfield/MaOutLineTextField.kt (1)
7-8: 사용되지 않는 import가 있습니다.
Column과Row가 import되어 있지만 이 composable에서 사용되지 않습니다.🔎 제안된 수정
import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSizefeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttState.kt (1)
3-8: LGTM!STT 상태를 표현하는 sealed interface가 깔끔하게 설계되었습니다.
Idle,Speaking,Success,Error상태가 명확하게 구분됩니다.Kotlin 1.9 이상을 사용 중이라면
object대신data object를 사용하여 더 나은toString()출력을 얻을 수 있습니다 (예:SttState.Idle대신Idle).feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizReadyContent.kt (1)
114-139:CardStackBackground의 가시성을 확인하세요.
CardStackBackground가 public으로 선언되어 있습니다. 이 컴포넌트가 다른 모듈에서 재사용될 예정이 아니라면private또는internal로 변경하는 것을 고려하세요.🔎 제안된 수정
@Composable -fun CardStackBackground( +private fun CardStackBackground( modifier: Modifier = Modifier, stackCount: Int = 3, offsetStep: Dp = 8.dp, ) {feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt (2)
3-7: 사용되지 않는 import 구문이 있습니다.다음 import들이 사용되지 않습니다:
android.Manifestandroid.content.pm.PackageManagerrememberLauncherForActivityResultActivityResultContractsLocalContextContextCompatAsyncImage권한 처리 로직이 ViewModel로 이동되어 이 import들이 더 이상 필요하지 않은 것으로 보입니다.
Also applies to: 18-18, 21-21, 24-24
107-148: 사용되지 않는 람다 파라미터가 있습니다.
QuizSlideAnimation의 람다 파라미터question이 선언되었지만 사용되지 않습니다. 외부 스코프의targetQuiz를 직접 사용하고 있습니다.🔎 수정 제안
QuizSlideAnimation( targetState = targetQuiz, modifier = Modifier.padding(bottom = 12.dp), - ) { question -> + ) { _ -> when (uiState.quizState) {feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/AndroidSttManager.kt (1)
34-48: 음성 인식 실패 시 상태 초기화가 필요합니다.
ERROR_NO_MATCH와ERROR_SPEECH_TIMEOUT을Success("")로 처리하는 것은 UX 관점에서 합리적입니다. 하지만 Success 또는 Error 상태 후Idle로 돌아가는 로직이 없어 후속 호출에서 상태가 꼬일 수 있습니다.ViewModel에서 상태 소비 후 reset을 호출하거나, 일정 시간 후 자동으로 Idle로 전환하는 방안을 고려해 주세요.
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/QuizCategoryViewModel.kt (1)
11-17: 사용되지 않는 import 구문들이 있습니다.UI 상태 관련 코드가 제거되면서 다음 import들이 더 이상 사용되지 않습니다:
MutableSharedFlow,MutableStateFlow,SharedFlow,StateFlowasSharedFlow,asStateFlow,updatefeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt (1)
105-142:checkAnswer와checkTextAnswer에 중복 코드가 있습니다.두 메서드 모두 동일한 패턴을 따릅니다:
- 퀴즈 정답 확인
- UI 상태 업데이트 (showResultDialog, quizResult, correctCount)
- 2초 딜레이 후 다음 문제로 이동
공통 로직을 추출하여 중복을 제거하는 것을 권장합니다.
🔎 리팩토링 제안
private fun processAnswer(isCorrect: Boolean) { _uiState.update { val correctAnswer = if (isCorrect) "" else it.currentQuiz?.answer?.joinToString(", ") ?: "" it.copy( showResultDialog = true, quizResult = QuizResult(isCorrect = isCorrect, correctAnswer = correctAnswer), correctCount = if (isCorrect) it.correctCount + 1 else it.correctCount, ) } viewModelScope.launch { delay(RESULT_DISPLAY_DURATION) goToNextQuestion() } } private fun checkAnswer(answer: String) { val quiz = _uiState.value.currentQuiz ?: return processAnswer(quiz.isAnswerCorrect(answer)) } fun checkTextAnswer() { val state = _uiState.value val quiz = state.currentQuiz ?: return processAnswer(quiz.isAnswerCorrect(state.userTextAnswers)) } companion object { private const val RESULT_DISPLAY_DURATION = 2000L }
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (41)
app/src/main/AndroidManifest.xmlapp/src/main/kotlin/com/moa/app/main/MainActivity.ktcore/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/textfield/MaOutLineTextField.ktcore/designsystem/src/main/res/drawable-hdpi/img_ready_card.webpcore/designsystem/src/main/res/drawable-mdpi/img_ready_card.webpcore/designsystem/src/main/res/drawable-xhdpi/img_ready_card.webpcore/designsystem/src/main/res/drawable-xxhdpi/img_ready_card.webpcore/designsystem/src/main/res/drawable-xxxhdpi/img_ready_card.webpcore/designsystem/src/main/res/drawable/ic_mic.xmlcore/navigation/src/main/java/com/moa/app/navigation/AppRoute.ktdata/src/main/kotlin/com/moa/app/data/quiz/model/response/AttentionQuizResponse.ktdata/src/main/kotlin/com/moa/app/data/quiz/model/response/LinguisticQuizResponse.ktdata/src/main/kotlin/com/moa/app/data/quiz/model/response/MemoryQuizResponse.ktdata/src/main/kotlin/com/moa/app/data/quiz/model/response/PersistenceQuizResponse.ktdata/src/main/kotlin/com/moa/app/data/quiz/model/response/QuizResponse.ktdata/src/main/kotlin/com/moa/app/data/quiz/model/response/SpaceTimeQuizResponse.ktdomain/src/main/kotlin/com/moa/app/domain/quiz/model/AttentionQuiz.ktdomain/src/main/kotlin/com/moa/app/domain/quiz/model/LinguisticQuiz.ktdomain/src/main/kotlin/com/moa/app/domain/quiz/model/MemoryQuiz.ktdomain/src/main/kotlin/com/moa/app/domain/quiz/model/PersistenceQuiz.ktdomain/src/main/kotlin/com/moa/app/domain/quiz/model/Quiz.ktdomain/src/main/kotlin/com/moa/app/domain/quiz/model/QuizCategory.ktdomain/src/main/kotlin/com/moa/app/domain/quiz/model/SpaceTimeQuiz.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeViewModel.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/QuizCategoryScreen.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/QuizCategoryViewModel.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/model/QuizCategoryUiState.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizCategoryCard.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizSlideAnimation.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizPlayContent.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizReadyContent.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizTextModeContent.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/AndroidSttManager.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttManager.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttModule.ktfeature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttState.kt
💤 Files with no reviewable changes (5)
- domain/src/main/kotlin/com/moa/app/domain/quiz/model/Quiz.kt
- feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/model/QuizCategoryUiState.kt
- feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/QuizCategoryScreen.kt
- domain/src/main/kotlin/com/moa/app/domain/quiz/model/QuizCategory.kt
- feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizCategoryCard.kt
🧰 Additional context used
🧬 Code graph analysis (5)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizTextModeContent.kt (3)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (1)
CenterQuizDescription(59-97)core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/textfield/MaOutLineTextField.kt (1)
MaOutLineTextField(30-81)core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/button/MaButton.kt (1)
MaButton(30-68)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizReadyContent.kt (1)
core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/button/MaButton.kt (1)
MaButton(30-68)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.kt (2)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (1)
TopQuizDescription(28-57)core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/button/MaButton.kt (1)
MaButton(30-68)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizPlayContent.kt (2)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt (1)
onImagesFinished(62-64)feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizSlideAnimation.kt (1)
CommonSideAnimation(45-76)
app/src/main/kotlin/com/moa/app/main/MainActivity.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt (1)
MemoryQuizScreen(39-79)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Run Unit Tests
🔇 Additional comments (33)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (2)
28-57: LGTM! 깔끔한 구현입니다.새로운
TopQuizDescription컴포저블이 잘 구현되었습니다. 기존 파일의 다른 컴포저블(CenterQuizDescription,BottomQuizDescription)과 일관된 패턴을 따르고 있으며, Compose 모범 사례를 잘 준수하고 있습니다:
clip모디파이어를background전에 적용하여 올바른 클리핑 처리- 테마 기반 색상 및 타이포그래피 사용
- 적절한 모디파이어 체이닝
139-145: 프리뷰 함수가 적절하게 구현되었습니다.프리뷰 함수가 표준 패턴을 따라 올바르게 구현되어 IDE에서 컴포저블을 미리 볼 수 있습니다.
feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.kt (1)
3-4: LGTM!런타임 권한 처리를 위한 필수 import 문들이 올바르게 추가되었습니다.
Also applies to: 7-8, 41-41
data/src/main/kotlin/com/moa/app/data/quiz/model/response/LinguisticQuizResponse.kt (1)
15-15: 명시적인 직렬화 매핑을 추가했습니다.프로퍼티 이름과 JSON 필드 이름이 동일하지만,
@SerialName어노테이션을 명시적으로 추가하는 것은 리팩토링으로부터 직렬화 계약을 보호하는 좋은 방어적 코딩 관행입니다.core/designsystem/src/main/res/drawable/ic_mic.xml (1)
1-9: 마이크 아이콘 리소스가 올바르게 추가되었습니다.표준 Android 벡터 드로어블 형식으로 올바르게 정의되었으며, 메모리 퀴즈의 음성 모드 기능을 지원합니다.
data/src/main/kotlin/com/moa/app/data/quiz/model/response/SpaceTimeQuizResponse.kt (1)
16-16: 명시적인 직렬화 매핑을 추가했습니다.다른 퀴즈 응답 모델들과 일관된 패턴으로
@SerialName어노테이션을 추가하여 직렬화 계약을 명확하게 만들었습니다.data/src/main/kotlin/com/moa/app/data/quiz/model/response/AttentionQuizResponse.kt (1)
14-14: 명시적인 직렬화 매핑을 추가했습니다.모든 퀴즈 응답 모델에 일관되게 적용된 변경사항으로, API 계약을 명확하게 유지합니다.
data/src/main/kotlin/com/moa/app/data/quiz/model/response/PersistenceQuizResponse.kt (1)
15-15: 명시적인 직렬화 매핑을 추가했습니다.모든 퀴즈 응답 타입에 일관된 직렬화 어노테이션을 적용하여 코드베이스의 일관성과 유지보수성을 향상시켰습니다.
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttManager.kt (1)
5-10: LGTM!STT 기능을 위한 깔끔하고 간결한 인터페이스입니다.
Flow<SttState>를 통한 상태 노출과 라이프사이클 메서드(startListening,stopListening,destroy)가 잘 정의되어 있습니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.kt (1)
67-86: LGTM!투명 배경의
MaButton을 사용한 "말할 수 없어요" 버튼 구현이 적절합니다. 색상 설정이 명확하고 일관성 있게 정의되어 있습니다.app/src/main/AndroidManifest.xml (2)
5-5: 런타임 권한 요청 구현 확인 필요
RECORD_AUDIO는 위험 권한(dangerous permission)으로, 런타임에 사용자 동의를 받아야 합니다. PR 설명에 따르면 권한 UX가 나중에 구현될 예정이지만, STT 기능 사용 전에 권한 요청 로직이 없으면SecurityException이 발생할 수 있습니다.
29-33: LGTM!Android 11(API 30) 이상에서 음성 인식 서비스를 쿼리하기 위한
<queries>블록이 올바르게 추가되었습니다.domain/src/main/kotlin/com/moa/app/domain/quiz/model/SpaceTimeQuiz.kt (1)
10-10: LGTM!
Quiz인터페이스에서answer속성이 제거됨에 따라override키워드를 제거한 것이 일관된 리팩토링입니다. 다른 퀴즈 모델(AttentionQuiz,LinguisticQuiz,PersistenceQuiz등)과 동일한 패턴을 따릅니다.app/src/main/kotlin/com/moa/app/main/MainActivity.kt (1)
91-91: LGTM!
MemoryQuizScreen네비게이션 연동이 기존 퀴즈 화면들(PersistenceQuiz,LinguisticQuiz,AttentionQuiz,SpaceTimeQuiz)과 일관된 패턴으로 추가되었습니다.core/navigation/src/main/java/com/moa/app/navigation/AppRoute.kt (1)
55-56: LGTM!MemoryQuiz 라우트가 다른 퀴즈 라우트들과 동일한 패턴으로 올바르게 추가되었습니다.
domain/src/main/kotlin/com/moa/app/domain/quiz/model/PersistenceQuiz.kt (1)
10-10: LGTM!
answer속성이 AttentionQuiz와 동일한 패턴으로 일반 속성으로 변경되었습니다. 이 변경사항은 Quiz 인터페이스 리팩토링의 일부로 일관성 있게 적용되었습니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttModule.kt (1)
1-16: SttModule 구성이 올바르게 설정되어 있습니다.SttManager 인터페이스와 AndroidSttManager 클래스가 존재하며, AndroidSttManager는 @Inject 생성자를 통해 Context를 주입받도록 구성되어 있습니다. Dagger/Hilt 모듈의 @BINDS 패턴과 SingletonComponent 스코프 설정이 올바르게 구현되어 있습니다.
data/src/main/kotlin/com/moa/app/data/quiz/model/response/QuizResponse.kt (1)
24-24: MemoryQuizResponse 구현 확인 완료.MemoryQuizResponse가 올바르게 구현되었고
toDomain()메서드가 존재함을 확인했습니다. 확장 함수는MemoryQuiz타입을 반환하며answer속성을 올바르게 사용하고 있습니다.when표현식은 모든 sealed class 케이스를 포함하므로 exhaustive합니다.domain/src/main/kotlin/com/moa/app/domain/quiz/model/AttentionQuiz.kt (1)
8-8: Quiz 인터페이스의 answer 속성 제거가 모든 구현체에서 일관되게 적용되었습니다.Quiz 인터페이스에서 answer 속성이 제거되었고, 모든 구현체(AttentionQuiz, MemoryQuiz, LinguisticQuiz, SpaceTimeQuiz, PersistenceQuiz)가 일관되게 답변을 일반 속성(
val answer)으로 변경했습니다. 각 구현체의isAnswerCorrect()메서드도 정상적으로 작동합니다.domain/src/main/kotlin/com/moa/app/domain/quiz/model/LinguisticQuiz.kt (1)
10-10: LGTM!
Quiz인터페이스에서answer속성을 제거하는 리팩토링에 맞춰override키워드가 제거되었습니다.isAnswerCorrect메서드 로직은 영향 없이 정상 동작합니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeViewModel.kt (1)
27-28: LGTM!타입 추론을 사용한
_uiState선언이 간결하고 적절합니다.core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/textfield/MaOutLineTextField.kt (1)
48-80: LGTM!
BasicTextField를 적절히 래핑하여 커스텀 스타일링을 적용했습니다.clip→background→border순서와 placeholder 로직이 올바르게 구현되었습니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizTextModeContent.kt (1)
48-73: 포커스 관리 구현이 적절합니다.
focusRequesters.getOrNull(index + 1)을 사용한 안전한 접근과 키보드 액션 처리가 잘 구현되었습니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizReadyContent.kt (1)
28-56: LGTM!
MemoryQuizReadyContentcomposable이 깔끔하게 구현되었습니다. 레이아웃 구조와Spacer를 활용한 버튼 배치가 적절합니다.data/src/main/kotlin/com/moa/app/data/quiz/model/response/MemoryQuizResponse.kt (1)
20-29:inputMethod와requiredSequenceType필드가 도메인 모델에 매핑되지 않습니다.
MemoryQuizResponse에서 명시적으로 역직렬화되는inputMethod와requiredSequenceType필드가MemoryQuiz도메인 모델로 전달되지 않습니다. 다른 퀴즈 타입들(AttentionQuiz의inputType,LinguisticQuiz의answerOptions)은 모두 해당 필드를 도메인 모델에 포함하고 있으므로, 이들 필드를MemoryQuiz에 추가하거나 의도적으로 제외한 이유를 명확히 해야 합니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt (1)
39-79: LGTM!
MemoryQuizScreen의 전체 구조가 잘 구현되었습니다:
collectAsStateWithLifecycle을 사용한 생명주기 인식 상태 수집BackHandler를 통한 뒤로가기 처리- 로딩 상태와 콘텐츠 상태의 적절한 분기
- 결과 다이얼로그와 종료 다이얼로그의 올바른 처리
domain/src/main/kotlin/com/moa/app/domain/quiz/model/MemoryQuiz.kt (2)
11-24: 순차 검색 로직이 중첩 매칭을 허용합니다.현재
isAnswerCorrect(String)구현에서lastIndex = currentIndex로 설정하여 다음 검색이 현재 매칭 위치 바로 다음부터 시작됩니다. 이로 인해 답변들이 서로 중첩될 수 있습니다.예:
answer = ["사과", "과일"],input = "사과일"→ "사과"가 위치 0에서 매칭되고, "과일"은 위치 1부터 검색되어 매칭 실패 (의도된 동작).STT 특성상 이 동작이 의도된 것인지 확인이 필요합니다. 만약 각 답변이 완전히 분리되어야 한다면
lastIndex = currentIndex + target.length - 1로 수정해야 합니다.
26-29: LGTM!텍스트 입력 모드를 위한
isAnswerCorrect(List<String>)구현이 명확합니다. 정확한 일치 검사가 타이핑된 답변에 적합합니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/AndroidSttManager.kt (1)
90-93: LGTM!리소스 정리 로직이 올바르게 구현되어 있습니다.
destroy()에서speechRecognizer를 해제하고 null로 설정하여 메모리 누수를 방지합니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/QuizCategoryViewModel.kt (1)
26-37: LGTM!메모리 퀴즈 네비게이션이 올바르게 추가되었습니다. 카테고리별 분기 처리가 명확하고,
QuizCategory.ALL의 no-op 처리도 적절합니다.feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt (3)
109-109: 오답 시 실제 정답 대신 "다른 값"이 표시됩니다.
correctAnswer에 실제 정답(quiz.answer) 대신 "다른 값"이라는 플레이스홀더가 설정됩니다. 이것이 의도된 UX인지 확인이 필요합니다. 사용자에게 실제 정답을 보여줘야 한다면 수정이 필요합니다.val correctAnswer = if (isCorrect) "" else quiz.answer.joinToString(", ")Also applies to: 129-129
48-56: LGTM!초기화 블록에서 권한 확인 후 텍스트 모드로 전환하는 로직이 적절합니다. PR 설명에 언급된 대로 런타임 권한 요청 UI는 추후 구현 예정으로 이해됩니다.
223-265: LGTM!
MemoryQuizUiState데이터 클래스가 잘 설계되었습니다:
@Immutable어노테이션으로 Compose 최적화 지원ImmutableList와PersistentList사용- 계산된 속성(
currentQuiz,totalSteps,currentStep)으로 편리한 접근 제공INIT컴패니언 객체로 초기 상태 명확히 정의
| val permissionLauncher = rememberLauncherForActivityResult( | ||
| contract = ActivityResultContracts.RequestPermission() | ||
| ) { isGranted: Boolean -> | ||
| // TODO 기획요구사항에 따라 권한 저장 구현하기 | ||
| } |
There was a problem hiding this comment.
권한 결과 처리 로직이 누락되었습니다.
권한 요청 결과를 처리하는 콜백이 비어있어 다음과 같은 문제가 발생할 수 있습니다:
- 사용자가 권한을 승인/거부해도 앱 상태에 반영되지 않습니다
- ViewModel에 권한 상태가 전달되지 않아 STT/텍스트 모드 전환이 불가능합니다
- PR 목표에 명시된 "권한 상태에 따른 모드 전환" 기능이 작동하지 않습니다
TODO 코멘트에 언급된 것처럼 권한 상태를 저장하고 ViewModel에 전달하는 로직이 필요합니다.
🔎 권한 상태 처리 예시
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
- // TODO 기획요구사항에 따라 권한 저장 구현하기
+ viewModel.updateRecordAudioPermission(isGranted)
}ViewModel에 권한 상태 업데이트 메서드 추가가 필요합니다.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In
feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.kt
around lines 57-61, the permission request callback is empty; implement it to
save the permission result and forward it to the ViewModel so the app state and
mode switching react to the user's choice. Specifically, call an existing or new
ViewModel method (e.g., setMicrophonePermissionGranted(isGranted: Boolean)) with
the isGranted value, update any local Compose state that drives UI/mode
(STT/text) and handle the denied case (e.g., set flag to show rationale or
navigate to settings) so the permission state is persisted in ViewModel and UI
updates accordingly.
| LaunchedEffect(Unit) { | ||
| viewModel.sideEffect | ||
| .flowWithLifecycle(lifecycleOwner.lifecycle) | ||
| .collect { sideEffect -> | ||
| when (sideEffect) { | ||
| is SeniorHomeSideEffect.ShowToast -> { | ||
| Toast.makeText(context, sideEffect.message, Toast.LENGTH_LONG).show() | ||
| } | ||
| } | ||
| } | ||
| val status = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) | ||
| if (status != PackageManager.PERMISSION_GRANTED) { | ||
| permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify permission handling and mode switching logic
# First, let's explore the structure
find feature/senior/src/main/kotlin/com/moa/app/feature/senior/ -type f -name "*.kt" | head -20Repository: team-memory-care/MoA-Android
Length of output: 1978
🏁 Script executed:
#!/bin/bash
# Search for RECORD_AUDIO and mode switching logic
rg -n --type=kotlin 'RECORD_AUDIO|checkSelfPermission' feature/senior/src/main/kotlin/com/moa/app/feature/senior/ -A 3 -B 3Repository: team-memory-care/MoA-Android
Length of output: 2050
🏁 Script executed:
#!/bin/bash
# Search for mode switching logic (TextMode, SttMode, textMode, sttMode)
rg -n --type=kotlin 'Mode|mode.*switch|SttMode|TextMode' feature/senior/src/main/kotlin/com/moa/app/feature/senior/ | head -40Repository: team-memory-care/MoA-Android
Length of output: 5723
🏁 Script executed:
#!/bin/bash
# Check the full context of SeniorHomeScreen.kt around lines 63-68
if [ -f "feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.kt" ]; then
wc -l feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.kt
echo "=== Lines 50-100 ==="
sed -n '50,100p' feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.kt
fiRepository: team-memory-care/MoA-Android
Length of output: 1740
🏁 Script executed:
#!/bin/bash
# Search for permission launcher callback handling
rg -n --type=kotlin 'permissionLauncher|ActivityResultLauncher.*RECORD_AUDIO' feature/senior/src/main/kotlin/com/moa/app/feature/senior/ -A 10Repository: team-memory-care/MoA-Android
Length of output: 2552
권한 요청 후속 처리가 구현되지 않았습니다.
permissionLauncher 콜백(59-61줄)이 비어있으며, 사용자의 권한 허용/거부 여부를 처리하지 않습니다:
현재 구현
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
// TODO 기획요구사항에 따라 권한 저장 구현하기
}- 콜백이 비어있어
isGranted값을 사용하지 않음 - 권한이 거부되었을 때 동작이 전혀 없음
MemoryQuizViewModel(52-54줄)에서 보여주는 올바른 패턴처럼 권한 거부 시 텍스트 모드로 전환해야 함
| @Composable | ||
| fun MemoryQuizPlayContent( | ||
| imageUrls: List<String>, | ||
| onImagesFinished: () -> Unit, | ||
| modifier: Modifier = Modifier, | ||
| ) { | ||
| var currentImageIndex by remember { mutableIntStateOf(0) } | ||
| val displayTimeMillis = 1200L | ||
| val offsetStep = 12.dp | ||
|
|
||
| val remainingCount = (imageUrls.size - 1 - currentImageIndex).coerceAtLeast(0) | ||
| val totalStackWidth = offsetStep * remainingCount | ||
|
|
||
| val centeringOffset by animateDpAsState( | ||
| targetValue = -(totalStackWidth / 2), | ||
| animationSpec = tween(600, easing = FastOutSlowInEasing), | ||
| label = "CenteringOffset", | ||
| ) | ||
|
|
||
| LaunchedEffect(currentImageIndex) { | ||
| delay(displayTimeMillis) | ||
| if (currentImageIndex < imageUrls.size - 1) { | ||
| currentImageIndex++ | ||
| } else { | ||
| delay(500) | ||
| onImagesFinished() | ||
| } | ||
| } |
There was a problem hiding this comment.
빈 이미지 목록에 대한 방어 로직 필요
imageUrls가 빈 리스트일 경우, currentImageIndex가 0인 상태에서 라인 93의 imageUrls[index] 접근 시 IndexOutOfBoundsException이 발생합니다.
🔎 제안된 수정
@Composable
fun MemoryQuizPlayContent(
imageUrls: List<String>,
onImagesFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
+ if (imageUrls.isEmpty()) {
+ onImagesFinished()
+ return
+ }
+
var currentImageIndex by remember { mutableIntStateOf(0) }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| @Composable | |
| fun MemoryQuizPlayContent( | |
| imageUrls: List<String>, | |
| onImagesFinished: () -> Unit, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| var currentImageIndex by remember { mutableIntStateOf(0) } | |
| val displayTimeMillis = 1200L | |
| val offsetStep = 12.dp | |
| val remainingCount = (imageUrls.size - 1 - currentImageIndex).coerceAtLeast(0) | |
| val totalStackWidth = offsetStep * remainingCount | |
| val centeringOffset by animateDpAsState( | |
| targetValue = -(totalStackWidth / 2), | |
| animationSpec = tween(600, easing = FastOutSlowInEasing), | |
| label = "CenteringOffset", | |
| ) | |
| LaunchedEffect(currentImageIndex) { | |
| delay(displayTimeMillis) | |
| if (currentImageIndex < imageUrls.size - 1) { | |
| currentImageIndex++ | |
| } else { | |
| delay(500) | |
| onImagesFinished() | |
| } | |
| } | |
| @Composable | |
| fun MemoryQuizPlayContent( | |
| imageUrls: List<String>, | |
| onImagesFinished: () -> Unit, | |
| modifier: Modifier = Modifier, | |
| ) { | |
| if (imageUrls.isEmpty()) { | |
| onImagesFinished() | |
| return | |
| } | |
| var currentImageIndex by remember { mutableIntStateOf(0) } | |
| val displayTimeMillis = 1200L | |
| val offsetStep = 12.dp | |
| val remainingCount = (imageUrls.size - 1 - currentImageIndex).coerceAtLeast(0) | |
| val totalStackWidth = offsetStep * remainingCount | |
| val centeringOffset by animateDpAsState( | |
| targetValue = -(totalStackWidth / 2), | |
| animationSpec = tween(600, easing = FastOutSlowInEasing), | |
| label = "CenteringOffset", | |
| ) | |
| LaunchedEffect(currentImageIndex) { | |
| delay(displayTimeMillis) | |
| if (currentImageIndex < imageUrls.size - 1) { | |
| currentImageIndex++ | |
| } else { | |
| delay(500) | |
| onImagesFinished() | |
| } | |
| } |
🤖 Prompt for AI Agents
In
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizPlayContent.kt
around lines 27 to 54, add a guard for an empty imageUrls list to avoid
IndexOutOfBoundsException: if imageUrls.isEmpty() either call onImagesFinished()
immediately or return a safe placeholder UI and skip the rest of the logic
(including LaunchedEffect and any indexing); ensure any computed values that use
imageUrls.size handle zero (you can early-return before computing
centeringOffset/currentImageIndex usage) so no access to imageUrls[index] occurs
when the list is empty.
| ) { | ||
| val focusManager = LocalFocusManager.current | ||
| val focusRequesters = remember { List(answers.size) { FocusRequester() } } | ||
| val labels = listOf("첫번째", "두번째", "세번째") |
There was a problem hiding this comment.
labels 리스트 크기와 answers 크기 불일치 시 IndexOutOfBoundsException 발생 가능
labels가 3개의 항목으로 하드코딩되어 있지만, answers의 크기가 3보다 클 경우 Line 66에서 labels[index] 접근 시 IndexOutOfBoundsException이 발생합니다.
🔎 제안된 수정
- val labels = listOf("첫번째", "두번째", "세번째")
+ val labels = listOf("첫번째", "두번째", "세번째", "네번째", "다섯번째")또는 getOrElse를 사용하여 안전하게 접근:
- text = "${labels[index]} 단어를 작성해주세요",
+ text = "${labels.getOrElse(index) { "${index + 1}번째" }} 단어를 작성해주세요",| val progress = remember { Animatable(0f) } | ||
|
|
||
| LaunchedEffect(isSpeaking) { | ||
| if (isSpeaking) { | ||
| progress.animateTo( | ||
| targetValue = 1f, | ||
| animationSpec = tween(durationMillis = 4000, easing = LinearEasing) | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
애니메이션 상태가 초기화되지 않음
isSpeaking이 false로 변경될 때 progress가 초기화되지 않습니다. 사용자가 다시 말하기를 시작하면 progress가 이미 1f인 상태여서 애니메이션이 작동하지 않습니다.
🔎 제안된 수정
LaunchedEffect(isSpeaking) {
if (isSpeaking) {
+ progress.snapTo(0f)
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 4000, easing = LinearEasing)
)
+ } else {
+ progress.snapTo(0f)
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| val progress = remember { Animatable(0f) } | |
| LaunchedEffect(isSpeaking) { | |
| if (isSpeaking) { | |
| progress.animateTo( | |
| targetValue = 1f, | |
| animationSpec = tween(durationMillis = 4000, easing = LinearEasing) | |
| ) | |
| } | |
| } | |
| val progress = remember { Animatable(0f) } | |
| LaunchedEffect(isSpeaking) { | |
| if (isSpeaking) { | |
| progress.snapTo(0f) | |
| progress.animateTo( | |
| targetValue = 1f, | |
| animationSpec = tween(durationMillis = 4000, easing = LinearEasing) | |
| ) | |
| } else { | |
| progress.snapTo(0f) | |
| } | |
| } |
🤖 Prompt for AI Agents
In
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.kt
around lines 136-145, the Animatable progress is not reset when isSpeaking turns
false so subsequent speaks start with progress==1f and the animation won't run;
update the LaunchedEffect to reset or snap progress back to 0f when isSpeaking
becomes false (or immediately before starting the animateTo when isSpeaking is
true) so each speaking session animates from 0f to 1f; use progress.snapTo(0f)
(or progress.animateTo(0f) if you want an explicit reset animation) and then
call progress.animateTo(...) when isSpeaking is true.
| showResultDialog = false, | ||
| quizResult = null, | ||
| quizState = MemoryQuizSetState.WAITING_TO_START, | ||
| userTextAnswers = persistentListOf("", "", ""), |
There was a problem hiding this comment.
userTextAnswers 크기가 3개로 하드코딩되어 있습니다.
userTextAnswers가 항상 3개의 빈 문자열로 초기화됩니다. 퀴즈마다 이미지/정답 개수가 다를 경우 문제가 발생할 수 있습니다.
🔎 동적 초기화 제안
// goToNextQuestion에서
val nextQuiz = state.quizzes.getOrNull(nextIndex)
val answerCount = nextQuiz?.answer?.size ?: 3
state.copy(
// ...
userTextAnswers = List(answerCount) { "" }.toPersistentList(),
)
// INIT에서는 빈 리스트로 시작하고, 퀴즈 로드 후 설정
userTextAnswers = persistentListOf()Also applies to: 262-262
🤖 Prompt for AI Agents
In
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt
around lines 157 and 262, userTextAnswers is hardcoded to three empty strings
which breaks when quizzes have variable answer counts; change initialization to
an empty persistent list in INIT and, when navigating/loading a quiz (e.g.,
goToNextQuestion or quiz load), set userTextAnswers to a persistent list sized
from the quiz's answer count (use nextQuiz?.answer?.size ?: default) filled with
empty strings so it dynamically matches each quiz's number of answers.
| onFailure = { t -> | ||
| Timber.e(t, "loadMemoryQuizzes failed") | ||
| _uiState.update { it.copy(isLoading = false) } | ||
| }, |
There was a problem hiding this comment.
퀴즈 로딩 실패 시 사용자에게 피드백이 없습니다.
onFailure에서 isLoading = false만 설정하고 errorMessage는 설정하지 않습니다. 사용자는 빈 화면을 보게 되며 무엇이 잘못되었는지 알 수 없습니다.
🔎 수정 제안
onFailure = { t ->
Timber.e(t, "loadMemoryQuizzes failed")
- _uiState.update { it.copy(isLoading = false) }
+ _uiState.update { it.copy(isLoading = false, errorMessage = "퀴즈를 불러오는데 실패했습니다") }
},📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| onFailure = { t -> | |
| Timber.e(t, "loadMemoryQuizzes failed") | |
| _uiState.update { it.copy(isLoading = false) } | |
| }, | |
| onFailure = { t -> | |
| Timber.e(t, "loadMemoryQuizzes failed") | |
| _uiState.update { it.copy(isLoading = false, errorMessage = "퀴즈를 불러오는데 실패했습니다") } | |
| }, |
🤖 Prompt for AI Agents
In
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt
around lines 199-202, onFailure only sets isLoading = false and leaves
errorMessage unset so the user gets no feedback; update the _uiState.update call
to also set a user-facing errorMessage (e.g., a localized string or a generic
"Failed to load quizzes" message) and optionally set any retry flag, while
keeping Timber.e(t, ...) for logging so the UI can display the error to the
user.
| override fun startListening() { | ||
| if (speechRecognizer == null) { | ||
| speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply { | ||
| setRecognitionListener(recognitionListener) | ||
| } | ||
| } | ||
|
|
||
| val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { | ||
| putExtra( | ||
| RecognizerIntent.EXTRA_LANGUAGE_MODEL, | ||
| RecognizerIntent.LANGUAGE_MODEL_FREE_FORM, | ||
| ) | ||
| putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ko-KR") | ||
| } | ||
| speechRecognizer?.startListening(intent) | ||
| } |
There was a problem hiding this comment.
SpeechRecognizer는 메인 스레드에서만 사용해야 합니다.
SpeechRecognizer는 메인 스레드에서 생성하고 사용해야 합니다. startListening()이 백그라운드 스레드에서 호출되면 크래시가 발생할 수 있습니다.
🔎 수정 제안
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
- override fun startListening() {
+ override suspend fun startListening() = withContext(Dispatchers.Main) {
if (speechRecognizer == null) {
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context).apply {
setRecognitionListener(recognitionListener)
}
}
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ko-KR")
}
speechRecognizer?.startListening(intent)
}인터페이스 SttManager의 startListening() 시그니처도 suspend fun으로 변경해야 합니다.
Committable suggestion skipped: line range outside the PR's diff.
Related issue 🛠
Work Description ✏️
Screenshot 📸
Uncompleted Tasks 😅
Summary by CodeRabbit
릴리스 노트
새로운 기능
리팩토링
기타
✏️ Tip: You can customize this high-level summary in your review settings.