Skip to content

[Feature/#53] 기억력 퀴즈를 구현합니다.#60

Merged
wjdrjs00 merged 13 commits intodevelopfrom
feature/53-memory-quiz
Jan 4, 2026
Merged

[Feature/#53] 기억력 퀴즈를 구현합니다.#60
wjdrjs00 merged 13 commits intodevelopfrom
feature/53-memory-quiz

Conversation

@wjdrjs00
Copy link
Collaborator

@wjdrjs00 wjdrjs00 commented Jan 4, 2026

Related issue 🛠

Work Description ✏️

  • 기억력 퀴즈를 구현했습니다.
    • 기억력 퀴즈 stt / text 모드 구현
    • stt 로직 구현

Screenshot 📸

  • N/A

Uncompleted Tasks 😅

  • 권한 설정 기획 확정 시 구현하기

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 메모리 퀴즈 게임 추가
    • 음성 인식 기능 지원 (음성 입력으로 답변 제공 가능)
    • 음성/텍스트 입력 모드 전환 기능
  • 리팩토링

    • UI 상태 관리 로직 단순화
  • 기타

    • 음성 녹음 권한 추가
    • 마이크 아이콘 추가

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 4, 2026

Walkthrough

메모리 퀴즈 기능을 완전히 구현합니다. STT(음성 인식) 통합, UI 컴포넌트, 도메인/데이터 모델, 뷰모델 및 네비게이션을 추가하고, 기존 퀴즈 카테고리 UI를 단순화합니다.

Changes

코호트 / 파일 변경 사항
매니페스트 & 네비게이션
app/src/main/AndroidManifest.xml, core/navigation/src/main/java/com/moa/app/navigation/AppRoute.kt, app/src/main/kotlin/com/moa/app/main/MainActivity.kt
RECORD_AUDIO 권한 추가 및 RecognitionService 쿼리 선언. AppRoute.MemoryQuiz 라우트 추가 및 MemoryQuizScreen NavHost 등록.
메모리 퀴즈 도메인 모델
domain/src/main/kotlin/com/moa/app/domain/quiz/model/MemoryQuiz.kt, domain/src/main/kotlin/com/moa/app/domain/quiz/model/Quiz.kt
MemoryQuiz 데이터 클래스 신규 추가 (답변: List<String>, 이미지 URL 지원, 문자열/리스트 기반 답변 검증 오버로드). Quiz 인터페이스에서 answer 프로퍼티 제거.
메모리 퀴즈 데이터 모델 & 응답
data/src/main/kotlin/com/moa/app/data/quiz/model/response/MemoryQuizResponse.kt, data/src/main/kotlin/com/moa/app/data/quiz/model/response/QuizResponse.kt, data/src/main/kotlin/.../AttentionQuizResponse.kt, data/src/main/kotlin/.../LinguisticQuizResponse.kt
MemoryQuizResponse 신규 추가 및 toDomain() 매퍼 포함. QuizResponse.toDomain()MemoryQuizResponse 분기 추가. 다른 응답 클래스들에 @SerialName("answer") 어노테이션 추가.
메모리 퀴즈 뷰모델 & 화면
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt, feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt
MemoryQuizScreen 신규 추가: 로딩, 콘텐츠, 결과/종료 다이얼로그 처리. MemoryQuizViewModel 신규 추가: STT 상태 관찰, 음성/텍스트 입력 관리, 점수 제출 통합. MemoryQuizUiState, MemoryQuizSetState, InputMode 열거형 신규 추가.
메모리 퀴즈 UI 컴포넌트
feature/senior/src/main/kotlin/.../memory/component/MemoryQuizReadyContent.kt, feature/senior/src/main/kotlin/.../memory/component/MemoryQuizPlayContent.kt, feature/senior/src/main/kotlin/.../memory/component/MemoryQuizTextModeContent.kt, feature/senior/src/main/kotlin/.../memory/component/MemoryQuizVoiceModeContent.kt
MemoryQuizReadyContent & CardStackBackground: 준비 상태 UI 및 카드 스택 배경. MemoryQuizPlayContent: 이미지 스택 애니메이션 및 진행률 관리. MemoryQuizTextModeContent: 텍스트 입력 필드 리스트 및 포커스 관리. MemoryQuizVoiceModeContent & VoiceInputButton: 음성 입력 UI 및 애니메이션.
STT 인프라
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttState.kt, feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttManager.kt, feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/AndroidSttManager.kt, feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttModule.kt
SttState: Idle, Speaking, Success, Error 상태 정의. SttManager 인터페이스: startListening(), stopListening(), destroy()sttState Flow. AndroidSttManager: Android SpeechRecognizer 통합, 에러 매핑 포함. SttModule: Hilt 바인딩.
퀴즈 카테고리 UI 단순화
feature/senior/src/main/kotlin/.../quiz/category/QuizCategoryScreen.kt, feature/senior/src/main/kotlin/.../quiz/category/QuizCategoryViewModel.kt, feature/senior/src/main/kotlin/.../quiz/category/model/QuizCategoryUiState.kt, feature/senior/src/main/kotlin/.../quiz/category/component/QuizCategoryCard.kt
QuizCategoryUiState 제거. QuizCategoryScreen & QuizCategoryViewModel에서 UI 상태/부수 효과 제거. QuizCategoryCard에서 isEnabled 파라미터 제거 및 조건부 시각화 제거. MEMORY & ALL 카테고리 네비게이션 분기 추가.
홈 화면 & 권한
feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.kt, feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeViewModel.kt
SeniorHomeScreen: RECORD_AUDIO 런타임 권한 처리 추가 (권한 런처 & 초기 확인). SeniorHomeViewModel: 부수 효과 플로우 제거 (스낵바 제거).
기타 도메인 모델
domain/src/main/kotlin/com/moa/app/domain/quiz/model/AttentionQuiz.kt, domain/src/main/kotlin/com/moa/app/domain/quiz/model/LinguisticQuiz.kt, domain/src/main/kotlin/com/moa/app/domain/quiz/model/PersistenceQuiz.kt, domain/src/main/kotlin/com/moa/app/domain/quiz/model/SpaceTimeQuiz.kt, domain/src/main/kotlin/com/moa/app/domain/quiz/model/QuizCategory.kt
기존 퀴즈 클래스들에서 override val answerval answer로 변경 (Quiz 인터페이스 제거로 인한). 코드 스타일 정리.
디자인 시스템
core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/textfield/MaOutLineTextField.kt, core/designsystem/src/main/res/drawable/ic_mic.xml
MaOutLineTextField 신규 추가: 테두리 아웃라인, 플레이스홀더 지원, 표준 텍스트 필드 설정. ic_mic.xml: 마이크 아이콘 벡터 드로어블.
애니메이션 & 컴포넌트
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizSlideAnimation.kt, feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt
CommonSideAnimation: 제네릭 애니메이션 래퍼 (페이드인/슬라이드아웃). CARD_TOSS_ANIMATION 상수 추가. TopQuizDescription: 헤더 텍스트 및 이미지 포함 퀴즈 설명 컴포넌트.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60분

Possibly related PRs

  • PR #52: SpaceTime 퀴즈 유형 추가로 AppRoute, MainActivity 라우팅, QuizResponse.toDomain() 동일 경로 수정 — 메모리 퀴즈와 동일한 아키텍처 패턴 따름.
  • PR #48: 퀴즈 모델 레이어 및 애니메이션 컴포넌트 수정 — MemoryQuiz와 유사한 도메인/데이터 모델 변경 및 QuizSlideAnimation 관련.
  • PR #23: QuizCategoryCard 및 퀴즈 카테고리 UI 수정 — 동일 파일(QuizCategoryCard, 카테고리 화면)에 대한 변경.

Poem

🐰 기억력 퀴즈가 생겼네, 음성도 텍스트도 가능해!
카드는 팔랑팔랑 쌓이고, STT는 열심히 들어주고,
답 맞췄을 땐 점수 올라, 틀렸을 땐 다시 해봐,
순서대로 외우고 말하고 입력하면 완성되는 기억력의 향연! 🎉✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Out of Scope Changes check ❓ Inconclusive 일부 변경사항(QuizCategoryScreen/ViewModel 수정, Quiz 인터페이스 answer 속성 제거)이 이슈 범위 밖으로 보이지만, 기본 quiz 인프라 개선으로 정당화될 수 있습니다. Quiz 인터페이스 변경과 카테고리 화면 수정이 기억력 퀴즈 통합을 위한 필수 리팩토링인지 명확히 하거나, 별도의 이슈로 분리하는 것을 고려하세요.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 기억력 퀴즈 구현이라는 주요 변경사항을 명확하게 요약하고 있습니다.
Description check ✅ Passed PR 설명이 템플릿 구조를 따르고 있으며, 관련 이슈, 작업 내용, 스크린샷, 미완료 사항을 모두 포함하고 있습니다.
Linked Issues check ✅ Passed PR의 모든 코드 변경사항이 이슈 #53의 요구사항을 충족합니다: 기억력 퀴즈 UI, 비즈니스 로직, API 연동, STT 구현이 모두 완료되었습니다.
✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 로딩/에러 상태 처리 고려

네트워크 이미지 로딩 실패 시 사용자에게 피드백이 없습니다. placeholdererror 파라미터를 추가하여 로딩 및 에러 상태를 처리하는 것을 고려해 주세요.

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.update
core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/textfield/MaOutLineTextField.kt (1)

7-8: 사용되지 않는 import가 있습니다.

ColumnRow가 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.fillMaxSize
feature/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.Manifest
  • android.content.pm.PackageManager
  • rememberLauncherForActivityResult
  • ActivityResultContracts
  • LocalContext
  • ContextCompat
  • AsyncImage

권한 처리 로직이 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_MATCHERROR_SPEECH_TIMEOUTSuccess("")로 처리하는 것은 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, StateFlow
  • asSharedFlow, asStateFlow, update
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt (1)

105-142: checkAnswercheckTextAnswer에 중복 코드가 있습니다.

두 메서드 모두 동일한 패턴을 따릅니다:

  1. 퀴즈 정답 확인
  2. UI 상태 업데이트 (showResultDialog, quizResult, correctCount)
  3. 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

📥 Commits

Reviewing files that changed from the base of the PR and between e2b6e15 and ea741f7.

📒 Files selected for processing (41)
  • app/src/main/AndroidManifest.xml
  • app/src/main/kotlin/com/moa/app/main/MainActivity.kt
  • core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/textfield/MaOutLineTextField.kt
  • core/designsystem/src/main/res/drawable-hdpi/img_ready_card.webp
  • core/designsystem/src/main/res/drawable-mdpi/img_ready_card.webp
  • core/designsystem/src/main/res/drawable-xhdpi/img_ready_card.webp
  • core/designsystem/src/main/res/drawable-xxhdpi/img_ready_card.webp
  • core/designsystem/src/main/res/drawable-xxxhdpi/img_ready_card.webp
  • core/designsystem/src/main/res/drawable/ic_mic.xml
  • core/navigation/src/main/java/com/moa/app/navigation/AppRoute.kt
  • data/src/main/kotlin/com/moa/app/data/quiz/model/response/AttentionQuizResponse.kt
  • data/src/main/kotlin/com/moa/app/data/quiz/model/response/LinguisticQuizResponse.kt
  • data/src/main/kotlin/com/moa/app/data/quiz/model/response/MemoryQuizResponse.kt
  • data/src/main/kotlin/com/moa/app/data/quiz/model/response/PersistenceQuizResponse.kt
  • data/src/main/kotlin/com/moa/app/data/quiz/model/response/QuizResponse.kt
  • data/src/main/kotlin/com/moa/app/data/quiz/model/response/SpaceTimeQuizResponse.kt
  • domain/src/main/kotlin/com/moa/app/domain/quiz/model/AttentionQuiz.kt
  • domain/src/main/kotlin/com/moa/app/domain/quiz/model/LinguisticQuiz.kt
  • domain/src/main/kotlin/com/moa/app/domain/quiz/model/MemoryQuiz.kt
  • domain/src/main/kotlin/com/moa/app/domain/quiz/model/PersistenceQuiz.kt
  • domain/src/main/kotlin/com/moa/app/domain/quiz/model/Quiz.kt
  • domain/src/main/kotlin/com/moa/app/domain/quiz/model/QuizCategory.kt
  • domain/src/main/kotlin/com/moa/app/domain/quiz/model/SpaceTimeQuiz.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/home/SeniorHomeViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/QuizCategoryScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/category/QuizCategoryViewModel.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/component/QuizCategoryCard.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizSlideAnimation.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizPlayContent.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizReadyContent.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizTextModeContent.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/AndroidSttManager.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttManager.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/stt/SttModule.kt
  • feature/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를 적절히 래핑하여 커스텀 스타일링을 적용했습니다. clipbackgroundborder 순서와 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!

MemoryQuizReadyContent composable이 깔끔하게 구현되었습니다. 레이아웃 구조와 Spacer를 활용한 버튼 배치가 적절합니다.

data/src/main/kotlin/com/moa/app/data/quiz/model/response/MemoryQuizResponse.kt (1)

20-29: inputMethodrequiredSequenceType 필드가 도메인 모델에 매핑되지 않습니다.

MemoryQuizResponse에서 명시적으로 역직렬화되는 inputMethodrequiredSequenceType 필드가 MemoryQuiz 도메인 모델로 전달되지 않습니다. 다른 퀴즈 타입들(AttentionQuizinputType, LinguisticQuizanswerOptions)은 모두 해당 필드를 도메인 모델에 포함하고 있으므로, 이들 필드를 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 최적화 지원
  • ImmutableListPersistentList 사용
  • 계산된 속성(currentQuiz, totalSteps, currentStep)으로 편리한 접근 제공
  • INIT 컴패니언 객체로 초기 상태 명확히 정의

Comment on lines +57 to +61
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
// TODO 기획요구사항에 따라 권한 저장 구현하기
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

권한 결과 처리 로직이 누락되었습니다.

권한 요청 결과를 처리하는 콜백이 비어있어 다음과 같은 문제가 발생할 수 있습니다:

  • 사용자가 권한을 승인/거부해도 앱 상태에 반영되지 않습니다
  • 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.

Comment on lines 63 to 68
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)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -20

Repository: 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 3

Repository: 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 -40

Repository: 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
fi

Repository: 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 10

Repository: 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줄)에서 보여주는 올바른 패턴처럼 권한 거부 시 텍스트 모드로 전환해야 함

Comment on lines +27 to +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()
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

빈 이미지 목록에 대한 방어 로직 필요

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.

Suggested change
@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("첫번째", "두번째", "세번째")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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}번째" }} 단어를 작성해주세요",

Comment on lines +136 to +145
val progress = remember { Animatable(0f) }

LaunchedEffect(isSpeaking) {
if (isSpeaking) {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 4000, easing = LinearEasing)
)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

애니메이션 상태가 초기화되지 않음

isSpeakingfalse로 변경될 때 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.

Suggested change
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("", "", ""),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +199 to +202
onFailure = { t ->
Timber.e(t, "loadMemoryQuizzes failed")
_uiState.update { it.copy(isLoading = false) }
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

퀴즈 로딩 실패 시 사용자에게 피드백이 없습니다.

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.

Suggested change
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.

Comment on lines +69 to +84
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)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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

인터페이스 SttManagerstartListening() 시그니처도 suspend fun으로 변경해야 합니다.

Committable suggestion skipped: line range outside the PR's diff.

@wjdrjs00 wjdrjs00 merged commit 81adba5 into develop Jan 4, 2026
4 checks passed
@wjdrjs00 wjdrjs00 deleted the feature/53-memory-quiz branch January 4, 2026 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 기억력 퀴즈를 구현합니다.

1 participant