diff --git a/Prezel/app/src/main/AndroidManifest.xml b/Prezel/app/src/main/AndroidManifest.xml index 246b5326..4a169428 100644 --- a/Prezel/app/src/main/AndroidManifest.xml +++ b/Prezel/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + (AudioSessionState.Idle) + override val audioSessionState: StateFlow = _audioSessionState.asStateFlow() + + private val _audioSessionEffect = Channel(capacity = Channel.BUFFERED) + override val audioSessionEffect: Flow = _audioSessionEffect.receiveAsFlow() + + private var recorder: MediaRecorder? = null + private var player: MediaPlayer? = null + private var currentAudioFile: File? = null + private var recordingTimerJob: Job? = null + private var playbackTimerJob: Job? = null + + override fun startRecording() { + runCatching { + stopPlayback() + releaseRecorder() + deleteCurrentAudioFile() + + val file = File.createTempFile("recording_", ".m4a", context.cacheDir) + var pendingRecorder: MediaRecorder? = null + val newRecorder = runCatching { + val recorder = createMediaRecorder(context = context) + pendingRecorder = recorder + recorder.apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(file.absolutePath) + prepare() + start() + } + }.getOrElse { throwable -> + pendingRecorder?.release() + file.delete() + throw throwable + } + + recorder = newRecorder + currentAudioFile = file + _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = 0) + startRecordingTimer() + }.onFailure { + releaseRecorder() + deleteCurrentAudioFile() + _audioSessionState.value = AudioSessionState.Idle + emitEffect(AudioSessionEffect.RecordingStartFailed) + } + } + + override fun stopRecording() { + val elapsedSeconds = when (val state = audioSessionState.value) { + is AudioSessionState.Recording -> state.elapsedSeconds + else -> return + } + val file = currentAudioFile ?: return emitEffect(AudioSessionEffect.RecordingStopFailed) + + recordingTimerJob?.cancel() + runCatching { + recorder?.stop() ?: error("Recording is not active.") + max(elapsedSeconds, 0) + }.onSuccess { durationSeconds -> + releaseRecorder() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = file.absolutePath), + durationSeconds = durationSeconds, + ) + }.onFailure { + releaseRecorder() + deleteCurrentAudioFile() + _audioSessionState.value = AudioSessionState.Idle + emitEffect(AudioSessionEffect.RecordingStopFailed) + } + } + + override fun startPlayback() { + when (val state = audioSessionState.value) { + is AudioSessionState.ReadyToPlay -> startPlayback( + source = state.source, + durationSeconds = state.durationSeconds, + startPositionSeconds = state.positionSeconds.takeIf { it < state.durationSeconds } ?: 0, + ) + + else -> Unit + } + } + + override fun stopPlayback() { + val readyState = when (val state = audioSessionState.value) { + is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay( + source = state.source, + positionSeconds = playbackPositionSeconds().coerceAtLeast(state.positionSeconds), + durationSeconds = state.durationSeconds, + ) + + else -> null + } + + releasePlayer() + if (readyState != null) { + _audioSessionState.value = readyState + } + } + + override fun reset() { + recordingTimerJob?.cancel() + playbackTimerJob?.cancel() + releaseRecorder() + releasePlayer() + deleteCurrentAudioFile() + _audioSessionState.value = AudioSessionState.Idle + } + + override fun release() { + reset() + controllerScope.cancel() + } + + private fun startPlayback( + source: AudioSource, + durationSeconds: Int, + startPositionSeconds: Int, + ) { + runCatching { + releasePlayer() + + var pendingPlayer: MediaPlayer? = null + val newPlayer = runCatching { + val mediaPlayer = MediaPlayer() + pendingPlayer = mediaPlayer + mediaPlayer.apply { + setDataSource(source.filePath) + prepare() + if (startPositionSeconds > 0) { + seekTo(startPositionSeconds * MILLIS_PER_SECOND) + } + setOnCompletionListener { + releasePlayer() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = source, + positionSeconds = durationSeconds, + durationSeconds = durationSeconds, + ) + } + start() + } + }.getOrElse { throwable -> + pendingPlayer?.release() + throw throwable + } + + player = newPlayer + _audioSessionState.value = AudioSessionState.Playing( + source = source, + positionSeconds = startPositionSeconds, + durationSeconds = durationSeconds.coerceAtLeast(newPlayer.duration.toSeconds()), + ) + startPlaybackTimer() + }.onFailure { + releasePlayer() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = source, + durationSeconds = durationSeconds, + ) + emitEffect(AudioSessionEffect.PlaybackStartFailed) + } + } + + private fun startRecordingTimer() { + recordingTimerJob?.cancel() + recordingTimerJob = controllerScope.launch { + while (true) { + delay(RECORDING_TIMER_DELAY_MILLIS) + _audioSessionState.update { state -> + if (state !is AudioSessionState.Recording) return@update state + AudioSessionState.Recording(elapsedSeconds = state.elapsedSeconds + 1) + } + } + } + } + + private fun startPlaybackTimer() { + playbackTimerJob?.cancel() + playbackTimerJob = controllerScope.launch { + while (true) { + delay(PLAYBACK_TIMER_DELAY_MILLIS) + _audioSessionState.update { state -> + if (state !is AudioSessionState.Playing) return@update state + AudioSessionState.Playing( + source = state.source, + positionSeconds = playbackPositionSeconds(), + durationSeconds = state.durationSeconds, + ) + } + } + } + } + + private fun releaseRecorder() { + recorder?.release() + recorder = null + } + + private fun releasePlayer() { + playbackTimerJob?.cancel() + player?.runCatching { stop() } + player?.release() + player = null + } + + private fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0 + + private fun deleteCurrentAudioFile() { + currentAudioFile?.delete() + currentAudioFile = null + } + + private fun emitEffect(effect: AudioSessionEffect) { + _audioSessionEffect.trySend(effect) + } + + private companion object { + const val MILLIS_PER_SECOND = 1_000 + const val RECORDING_TIMER_DELAY_MILLIS = 1_000L + const val PLAYBACK_TIMER_DELAY_MILLIS = 250L + } +} + +@Suppress("DEPRECATION") +private fun createMediaRecorder(context: Context): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } + +private fun Int.toSeconds(): Int = this / 1_000 diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt new file mode 100644 index 00000000..6d7377c2 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt @@ -0,0 +1,22 @@ +package com.team.prezel.core.audio + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface RecordingAudioController { + val audioSessionState: StateFlow + + val audioSessionEffect: Flow + + fun startRecording() + + fun stopRecording() + + fun startPlayback() + + fun stopPlayback() + + fun reset() + + fun release() +} diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt new file mode 100644 index 00000000..f91b3d42 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.audio + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class RecordingAudioModule { + @Binds + @ViewModelScoped + abstract fun bindRecordingAudioController(impl: MediaRecordingAudioController): RecordingAudioController +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index 07f4445a..dad55b3f 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -3,7 +3,9 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.repository.AuthRepositoryImpl import com.team.prezel.core.data.repository.TermsRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl +import com.team.prezel.core.data.repository.practice.PracticeRepositoryImpl import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.domain.repository.practice.PracticeRepository import com.team.prezel.core.domain.repository.profile.UserRepository import com.team.prezel.core.domain.repository.terms.TermsRepository import dagger.Binds @@ -23,6 +25,10 @@ internal abstract class RepositoryModule { @Singleton abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository + @Binds + @Singleton + abstract fun bindPracticeRepository(impl: PracticeRepositoryImpl): PracticeRepository + @Binds @Singleton abstract fun bindTermsRepository(impl: TermsRepositoryImpl): TermsRepository diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt new file mode 100644 index 00000000..58c48c2c --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.kt @@ -0,0 +1,68 @@ +package com.team.prezel.core.data.repository.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.core.model.practice.PracticeRecordingUpload +import com.team.prezel.core.model.practice.PracticeScript +import kotlinx.coroutines.delay +import javax.inject.Inject + +internal class PracticeRepositoryImpl @Inject constructor() : PracticeRepository { + override suspend fun fetchPracticeScript(): Result = + runCatching { + delay(FAKE_API_DELAY_MILLIS) + fakePracticeScripts.random() + } + + override suspend fun uploadPracticeRecording(recordingFilePath: String): Result = + runCatching { + delay(FAKE_API_DELAY_MILLIS) + PracticeRecordingUpload(id = FAKE_RECORDING_ID) + } + + override suspend fun fetchPracticeRecordingAnalysisResult(recordingId: Long): Result = + runCatching { + delay(FAKE_API_DELAY_MILLIS) + PracticeRecordingAnalysisResult( + pronunciationScore = 90, + speed = PracticeRecordingSpeed.ADEQUATE, + ) + } + + private companion object { + const val FAKE_API_DELAY_MILLIS = 300L + const val FAKE_RECORDING_ID = 1L + + val fakePracticeScripts = listOf( + PracticeScript( + id = 1L, + content = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", + ), + PracticeScript( + id = 2L, + content = "간장 공장 공장장은 강 공장장이고,\n된장 공장 공장장은 공 공장장이다.", + ), + PracticeScript( + id = 3L, + content = "저기 있는 말뚝이 말 맬 말뚝이냐,\n말 못 맬 말뚝이냐.", + ), + PracticeScript( + id = 4L, + content = "서울특별시 특허허가과 허가과장 허 과장.", + ), + PracticeScript( + id = 5L, + content = "신진 샹송 가수의 신춘 샹송 쇼.", + ), + PracticeScript( + id = 6L, + content = "작년에 온 솥 장수는 새 솥 장수이고,\n금년에 온 솥 장수는 헌 솥 장수이다.", + ), + PracticeScript( + id = 7L, + content = "상표 붙인 큰 깡통은 깐 깡통인가,\n안 깐 깡통인가.", + ), + ) + } +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt new file mode 100644 index 00000000..065fecfc --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt @@ -0,0 +1,13 @@ +package com.team.prezel.core.domain.repository.practice + +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingUpload +import com.team.prezel.core.model.practice.PracticeScript + +interface PracticeRepository { + suspend fun fetchPracticeScript(): Result + + suspend fun uploadPracticeRecording(recordingFilePath: String): Result + + suspend fun fetchPracticeRecordingAnalysisResult(recordingId: Long): Result +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt new file mode 100644 index 00000000..169db33d --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import javax.inject.Inject + +/** + * 연습 녹음본을 업로드하고 분석 결과를 조회하는 UseCase. + */ +class AnalyzePracticeRecordingUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(recordingFilePath: String): Result = + practiceRepository + .uploadPracticeRecording(recordingFilePath = recordingFilePath) + .mapCatching { upload -> + practiceRepository.fetchPracticeRecordingAnalysisResult(recordingId = upload.id).getOrThrow() + } +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt new file mode 100644 index 00000000..af3615c0 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import javax.inject.Inject + +/** + * 업로드된 연습 녹음본의 분석 결과를 조회하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 녹음본 ID를 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.practice.PracticeRepository.fetchPracticeRecordingAnalysisResult]를 호출하여 분석 결과 조회를 요청합니다. + * 3. 조회 결과에 따라 분석 결과 또는 예외를 포함한 [Result]를 반환합니다. + */ +class FetchPracticeRecordingAnalysisResultUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(recordingId: Long): Result = + practiceRepository.fetchPracticeRecordingAnalysisResult(recordingId = recordingId) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt new file mode 100644 index 00000000..0fc0ade0 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeScript +import javax.inject.Inject + +/** + * 연습 녹음에 사용할 대본을 조회하는 UseCase. + * + * ### 동작 흐름 + * 1. [com.team.prezel.core.domain.repository.practice.PracticeRepository.fetchPracticeScript]를 호출하여 연습 대본 조회를 요청합니다. + * 2. repository가 서버 또는 임시 데이터 소스로부터 대본을 가져옵니다. + * 3. 조회 결과에 따라 연습 대본 또는 예외를 포함한 [Result]를 반환합니다. + */ +class FetchPracticeScriptUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(): Result = practiceRepository.fetchPracticeScript() +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt new file mode 100644 index 00000000..7f48476a --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.domain.usecase.practice + +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingUpload +import javax.inject.Inject + +/** + * 연습 녹음본 파일을 업로드하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 녹음본 파일 경로를 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.practice.PracticeRepository.uploadPracticeRecording]을 호출하여 녹음본 업로드를 요청합니다. + * 3. 업로드 결과에 따라 녹음본 업로드 정보 또는 예외를 포함한 [Result]를 반환합니다. + */ +class UploadPracticeRecordingUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(recordingFilePath: String): Result = + practiceRepository.uploadPracticeRecording(recordingFilePath = recordingFilePath) +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt new file mode 100644 index 00000000..499e592d --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.model.practice + +data class PracticeRecordingAnalysisResult( + val pronunciationScore: Int, + val speed: PracticeRecordingSpeed, +) + +enum class PracticeRecordingSpeed { + SLOW, + ADEQUATE, + FAST, +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt new file mode 100644 index 00000000..9120fa49 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.model.practice + +data class PracticeRecordingUpload( + val id: Long, +) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.kt new file mode 100644 index 00000000..d633d215 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.kt @@ -0,0 +1,6 @@ +package com.team.prezel.core.model.practice + +data class PracticeScript( + val id: Long, + val content: String, +) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt new file mode 100644 index 00000000..65c1340d --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.kt @@ -0,0 +1,100 @@ +package com.team.prezel.core.ui.util + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner + +@Composable +fun rememberPermissionRequest( + permission: String, + onPermissionGranted: () -> Unit, + onPermissionDenied: () -> Unit, + onPermissionPermanentlyDenied: () -> Unit, +): PermissionRequest { + val context = LocalContext.current + val activity = LocalActivity.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnPermissionGranted by rememberUpdatedState(onPermissionGranted) + val currentOnPermissionDenied by rememberUpdatedState(onPermissionDenied) + val currentOnPermissionPermanentlyDenied by rememberUpdatedState(onPermissionPermanentlyDenied) + var isGranted by remember(permission) { mutableStateOf(context.isPermissionGranted(permission)) } + var hasRequestedPermission by remember(permission) { mutableStateOf(false) } + var isPermanentlyDenied by remember(permission) { mutableStateOf(false) } + + fun syncPermissionState() { + val syncedIsGranted = context.isPermissionGranted(permission) + isGranted = syncedIsGranted + isPermanentlyDenied = !syncedIsGranted && + hasRequestedPermission && + activity.isPermissionPermanentlyDenied(permission) + } + + DisposableEffect(lifecycleOwner, context, activity, permission) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + syncPermissionState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { launcherIsGranted -> + hasRequestedPermission = true + isGranted = launcherIsGranted + isPermanentlyDenied = !launcherIsGranted && activity.isPermissionPermanentlyDenied(permission) + when { + launcherIsGranted -> { + currentOnPermissionGranted() + } + isPermanentlyDenied -> { + currentOnPermissionPermanentlyDenied() + } + else -> { + currentOnPermissionDenied() + } + } + } + + return PermissionRequest( + isGranted = isGranted, + isPermanentlyDenied = isPermanentlyDenied, + launch = { launcher.launch(permission) }, + onPermanentlyDenied = currentOnPermissionPermanentlyDenied, + ) +} + +private fun Context.isPermissionGranted(permission: String): Boolean = + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED + +private fun Activity?.isPermissionPermanentlyDenied(permission: String): Boolean = + this?.let { + !ActivityCompat.shouldShowRequestPermissionRationale(it, permission) + } == true + +data class PermissionRequest( + val isGranted: Boolean, + val isPermanentlyDenied: Boolean, + val launch: () -> Unit, + val onPermanentlyDenied: () -> Unit, +) diff --git a/Prezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xml b/Prezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xml new file mode 100644 index 00000000..debd560c --- /dev/null +++ b/Prezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + diff --git a/Prezel/core/ui/src/main/res/drawable/core_ui_error_voice.xml b/Prezel/core/ui/src/main/res/drawable/core_ui_error_voice.xml new file mode 100644 index 00000000..23b46fdf --- /dev/null +++ b/Prezel/core/ui/src/main/res/drawable/core_ui_error_voice.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index 8e5ce3e3..96efc948 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -7,6 +7,8 @@ android { } dependencies { + implementation(projects.coreAudio) + implementation(projects.coreDomain) implementation(projects.coreModel) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt similarity index 80% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index 1937c35a..b4f66b6a 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl +package com.team.prezel.feature.home.impl.main import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -24,19 +24,22 @@ import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelS import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.core.ui.util.onHeightChanged -import com.team.prezel.feature.home.impl.component.HomePageLayout -import com.team.prezel.feature.home.impl.component.body.EmptyPresentationSheet -import com.team.prezel.feature.home.impl.component.body.PresentationSheet -import com.team.prezel.feature.home.impl.component.head.HomeHeadSection -import com.team.prezel.feature.home.impl.component.title.EmptyPresentationHero -import com.team.prezel.feature.home.impl.component.title.PresentationHero -import com.team.prezel.feature.home.impl.contract.HomeUiEffect -import com.team.prezel.feature.home.impl.contract.HomeUiIntent -import com.team.prezel.feature.home.impl.contract.HomeUiState -import com.team.prezel.feature.home.impl.model.HomeUiMessage -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.main.component.HomePageLayout +import com.team.prezel.feature.home.impl.main.component.body.EmptyPresentationSheet +import com.team.prezel.feature.home.impl.main.component.body.PresentationSheet +import com.team.prezel.feature.home.impl.main.component.head.HomeHeadSection +import com.team.prezel.feature.home.impl.main.component.title.EmptyPresentationHero +import com.team.prezel.feature.home.impl.main.component.title.PresentationHero +import com.team.prezel.feature.home.impl.main.contract.HomeUiEffect +import com.team.prezel.feature.home.impl.main.contract.HomeUiIntent +import com.team.prezel.feature.home.impl.main.contract.HomeUiState +import com.team.prezel.feature.home.impl.main.model.HomeUiMessage +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel +import com.team.prezel.feature.home.impl.navigation.PracticeRecordingNavKey import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate @@ -50,6 +53,7 @@ internal fun HomeScreen( val pagerState = rememberPagerState(0) { uiState.presentationCount() } val snackbarHostState = LocalSnackbarHostState.current val resources = LocalResources.current + val navigator = LocalNavigator.current LaunchedEffect(Unit) { viewModel.onIntent(HomeUiIntent.FetchData) @@ -70,6 +74,7 @@ internal fun HomeScreen( uiState = uiState, pagerState = pagerState, onClickAddPresentation = { }, + onClickPracticeRecording = { navigator.navigate(PracticeRecordingNavKey) }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, modifier = modifier, @@ -82,6 +87,7 @@ private fun HomeScreen( uiState: HomeUiState, pagerState: PagerState, onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, modifier: Modifier = Modifier, @@ -99,6 +105,7 @@ private fun HomeScreen( maxHeight = maxScreenHeight, headerHeight = headerHeight, onClickAddPresentation = onClickAddPresentation, + onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, ) @@ -120,6 +127,7 @@ private fun HomeContent( maxHeight: Dp, headerHeight: Dp, onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, ) { @@ -131,6 +139,7 @@ private fun HomeContent( headerHeight = headerHeight, uiState = uiState, onClickAddPresentation = onClickAddPresentation, + onClickPracticeRecording = onClickPracticeRecording, ) } @@ -139,6 +148,7 @@ private fun HomeContent( uiState = uiState, maxHeight = maxHeight, headerHeight = headerHeight, + onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, ) @@ -150,6 +160,7 @@ private fun HomeContent( pagerState = pagerState, maxHeight = maxHeight, headerHeight = headerHeight, + onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, ) @@ -163,11 +174,12 @@ private fun HomeEmptyContent( headerHeight: Dp, uiState: HomeUiState.Empty, onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, ) { HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { EmptyPresentationSheet() }, + sheetContent = { EmptyPresentationSheet(onClickPracticeRecording = onClickPracticeRecording) }, heroContent = { EmptyPresentationHero( nickname = uiState.nickname, @@ -182,6 +194,7 @@ private fun HomeSingleContent( uiState: HomeUiState.SingleContent, maxHeight: Dp, headerHeight: Dp, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, ) { @@ -190,7 +203,12 @@ private fun HomeSingleContent( HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { PresentationSheet(practiceCount = presentation.practiceCount) }, + sheetContent = { + PresentationSheet( + practiceCount = presentation.practiceCount, + onClickPracticeRecording = onClickPracticeRecording, + ) + }, heroContent = { PresentationHero( presentation = presentation, @@ -207,6 +225,7 @@ private fun HomeMultipleContent( pagerState: PagerState, maxHeight: Dp, headerHeight: Dp, + onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, ) { @@ -222,7 +241,12 @@ private fun HomeMultipleContent( HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { PresentationSheet(practiceCount = presentation.practiceCount) }, + sheetContent = { + PresentationSheet( + practiceCount = presentation.practiceCount, + onClickPracticeRecording = onClickPracticeRecording, + ) + }, heroContent = { PresentationHero( presentation = presentation, @@ -243,6 +267,7 @@ private fun HomeScreenEmptyPreview() { uiState = uiState, pagerState = rememberPagerState(0) { uiState.presentationCount() }, onClickAddPresentation = { }, + onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, ) @@ -266,6 +291,7 @@ private fun HomeScreenSinglePreview() { uiState = uiState, pagerState = rememberPagerState(0) { uiState.presentationCount() }, onClickAddPresentation = { }, + onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, ) @@ -291,6 +317,7 @@ private fun HomeScreenMultiplePreview() { uiState = uiState, pagerState = rememberPagerState(0) { uiState.presentationCount() }, onClickAddPresentation = { }, + onClickPracticeRecording = { }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt similarity index 83% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt index b51ec51f..5f314636 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl +package com.team.prezel.feature.home.impl.main import androidx.lifecycle.viewModelScope import com.team.prezel.core.model.presentation.Audience @@ -7,11 +7,11 @@ import com.team.prezel.core.model.presentation.Presentation import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.ui.base.BaseViewModel -import com.team.prezel.feature.home.impl.contract.HomeUiEffect -import com.team.prezel.feature.home.impl.contract.HomeUiIntent -import com.team.prezel.feature.home.impl.contract.HomeUiState -import com.team.prezel.feature.home.impl.model.PresentationUiModel -import com.team.prezel.feature.home.impl.model.PresentationUiModel.Companion.toUiModel +import com.team.prezel.feature.home.impl.main.contract.HomeUiEffect +import com.team.prezel.feature.home.impl.main.contract.HomeUiIntent +import com.team.prezel.feature.home.impl.main.contract.HomeUiState +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel.Companion.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomePageLayout.kt similarity index 93% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomePageLayout.kt index 95b2bf8d..690d3fe4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomePageLayout.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component +package com.team.prezel.feature.home.impl.main.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -24,9 +24,9 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.util.onHeightChanged import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.component.body.HomeBottomSheetContent -import com.team.prezel.feature.home.impl.component.body.HomeBottomSheetTitle -import com.team.prezel.feature.home.impl.component.title.HomeHeroLayout +import com.team.prezel.feature.home.impl.main.component.body.HomeBottomSheetContent +import com.team.prezel.feature.home.impl.main.component.body.HomeBottomSheetTitle +import com.team.prezel.feature.home.impl.main.component.title.HomeHeroLayout private data class HomeBottomSheetLayoutState( val sheetPeekHeight: Dp, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt similarity index 64% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt index 30b5be30..0ee5c218 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt @@ -1,24 +1,34 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.home.impl.R @Composable -internal fun EmptyPresentationSheet(modifier: Modifier = Modifier) { +internal fun EmptyPresentationSheet( + onClickPracticeRecording: () -> Unit, + modifier: Modifier = Modifier, +) { HomeBottomSheetContent( modifier = modifier, contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), ) { HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_empty_title)) + Spacer(modifier = Modifier.height(12.dp)) + PrezelButton( + text = stringResource(R.string.feature_home_impl_practice_recording_action), + onClick = onClickPracticeRecording, + ) } } @@ -31,7 +41,7 @@ private fun EmptyPresentationContentPreview() { .height(100.dp) .padding(top = 16.dp), ) { - EmptyPresentationSheet() + EmptyPresentationSheet(onClickPracticeRecording = {}) } } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetContent.kt similarity index 98% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetContent.kt index dd1173fb..8349ee6b 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetContent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt similarity index 93% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt index 9b8aec7c..ee4efc4c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt similarity index 60% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index dd8f59e3..a98c7247 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -1,31 +1,44 @@ -package com.team.prezel.feature.home.impl.component.body +package com.team.prezel.feature.home.impl.main.component.body import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.datetime.LocalDate @Composable internal fun PresentationSheet( practiceCount: Int, + onClickPracticeRecording: () -> Unit, modifier: Modifier = Modifier, ) { val itemModifier = Modifier.padding(horizontal = PrezelTheme.spacing.V20) - HomeBottomSheetContent(modifier = modifier) { + HomeBottomSheetContent( + modifier = modifier, + contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32), + ) { HomeBottomSheetTitle( title = stringResource(R.string.feature_home_impl_bottom_sheet_content_title, practiceCount), modifier = itemModifier, ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + PrezelButton( + text = stringResource(R.string.feature_home_impl_practice_recording_action), + modifier = itemModifier, + onClick = onClickPracticeRecording, + ) } } @@ -47,7 +60,10 @@ private fun PresentationContentPreview() { .height(100.dp) .padding(top = 16.dp), ) { - PresentationSheet(practiceCount = presentation.practiceCount) + PresentationSheet( + practiceCount = presentation.practiceCount, + onClickPracticeRecording = {}, + ) } } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt similarity index 94% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt index 36e53fc3..6b9a814d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.head +package com.team.prezel.feature.home.impl.main.component.head import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -17,8 +17,8 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.contract.HomeUiState -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.contract.HomeUiState +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.LocalDate diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/EmptyPresentationHero.kt similarity index 96% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/EmptyPresentationHero.kt index f6d3563c..89be3899 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/EmptyPresentationHero.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/HomeHeroLayout.kt similarity index 96% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/HomeHeroLayout.kt index 2f225e73..c61348f1 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/HomeHeroLayout.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Column diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PracticeActionCard.kt similarity index 97% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PracticeActionCard.kt index 6619074d..6547f2cd 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PracticeActionCard.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt similarity index 97% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt index f0be1bdc..a9934a01 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.component.title +package com.team.prezel.feature.home.impl.main.component.title import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -17,7 +17,7 @@ import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.model.presentation.Category import com.team.prezel.feature.home.impl.R -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.datetime.LocalDate import kotlinx.datetime.number diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiEffect.kt similarity index 60% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiEffect.kt index ad982ba7..60d98093 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiEffect.kt @@ -1,7 +1,7 @@ -package com.team.prezel.feature.home.impl.contract +package com.team.prezel.feature.home.impl.main.contract import com.team.prezel.core.ui.base.UiEffect -import com.team.prezel.feature.home.impl.model.HomeUiMessage +import com.team.prezel.feature.home.impl.main.model.HomeUiMessage internal sealed interface HomeUiEffect : UiEffect { data class ShowMessage( diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt similarity index 71% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt index 103fa67f..cc2fc967 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.contract +package com.team.prezel.feature.home.impl.main.contract import com.team.prezel.core.ui.base.UiIntent diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt similarity index 91% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt index 742cb022..cf6b5cd9 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt @@ -1,8 +1,8 @@ -package com.team.prezel.feature.home.impl.contract +package com.team.prezel.feature.home.impl.main.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.base.UiState -import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/HomeUiMessage.kt similarity index 50% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/HomeUiMessage.kt index 5cd796f8..5ddf2ee7 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/HomeUiMessage.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.model +package com.team.prezel.feature.home.impl.main.model enum class HomeUiMessage { FETCH_DATA_FAILED, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt similarity index 95% rename from Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt rename to Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt index 5d7d055e..78e1766d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt @@ -1,4 +1,4 @@ -package com.team.prezel.feature.home.impl.model +package com.team.prezel.feature.home.impl.main.model import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.Category diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt index d13244ef..a8938159 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.kt @@ -2,8 +2,10 @@ package com.team.prezel.feature.home.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.home.api.HomeNavKey -import com.team.prezel.feature.home.impl.HomeScreen +import com.team.prezel.feature.home.impl.main.HomeScreen +import com.team.prezel.feature.home.impl.practice.PracticeRecordingScreen import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -14,6 +16,15 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { entry { HomeScreen() } + + entry { + val navigator = LocalNavigator.current + + PracticeRecordingScreen( + onBack = navigator::goBack, + navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + ) + } } @Module diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt new file mode 100644 index 00000000..8e95d3a9 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.home.impl.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +internal data object PracticeRecordingNavKey : NavKey diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt new file mode 100644 index 00000000..cde7dadf --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt @@ -0,0 +1,252 @@ +package com.team.prezel.feature.home.impl.practice + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingContent +import com.team.prezel.feature.home.impl.practice.component.PracticeRecordingTopAppBar +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage +import com.team.prezel.feature.home.impl.practice.result.PracticeRecordingResultScreen +import kotlinx.coroutines.flow.collectLatest + +@Composable +internal fun PracticeRecordingScreen( + onBack: () -> Unit, + navigateToHome: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PracticeRecordingViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + val onStartRecording = rememberRecordAudioPermissionControlClickHandler( + recordingState = uiState.recordingState, + onStartRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StartRecording) }, + onPermissionDenied = { viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionDenied) }, + onPermissionPermanentlyDenied = { + viewModel.onIntent(PracticeRecordingUiIntent.RecordAudioPermissionPermanentlyDenied) + }, + ) + + LaunchedEffect(Unit) { + viewModel.onIntent(PracticeRecordingUiIntent.LoadPracticeScript) + } + + LaunchedEffect(Unit) { + viewModel.uiEffect.collectLatest { effect -> + when (effect) { + is PracticeRecordingUiEffect.ShowMessage -> { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar( + message = resources.getString(effect.message.resId), + useRaisedPosition = false, + ) + } + } + } + } + + PracticeRecordingScreen( + uiState = uiState, + onStartRecording = onStartRecording, + onStopRecording = { viewModel.onIntent(PracticeRecordingUiIntent.StopRecording) }, + onStartPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StartPlayback) }, + onStopPlayback = { viewModel.onIntent(PracticeRecordingUiIntent.StopPlayback) }, + onClickAnalyze = { viewModel.onIntent(PracticeRecordingUiIntent.AnalyzeClicked) }, + onRetryRecording = { viewModel.onIntent(PracticeRecordingUiIntent.RetryRecordingClicked) }, + onBack = onBack, + navigateToHome = navigateToHome, + modifier = modifier, + ) +} + +@Composable +private fun PracticeRecordingScreen( + uiState: PracticeRecordingUiState, + onStartRecording: () -> Unit, + onStopRecording: () -> Unit, + onStartPlayback: () -> Unit, + onStopPlayback: () -> Unit, + onClickAnalyze: () -> Unit, + onRetryRecording: () -> Unit, + onBack: () -> Unit, + navigateToHome: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler( + onBack = { + if (uiState.analysisStatus == PracticeRecordingAnalysisStatus.Ready) { + onBack() + } + }, + ) + + when (uiState.analysisStatus) { + PracticeRecordingAnalysisStatus.Ready -> PracticeRecordingReadyScreen( + uiState = uiState, + onStartRecording = onStartRecording, + onStopRecording = onStopRecording, + onStartPlayback = onStartPlayback, + onStopPlayback = onStopPlayback, + onClickAnalyze = onClickAnalyze, + onBack = onBack, + modifier = modifier, + ) + + else -> PracticeRecordingResultScreen( + analysisStatus = uiState.analysisStatus, + onRetry = onRetryRecording, + onComplete = navigateToHome, + modifier = modifier, + ) + } +} + +@Composable +private fun PracticeRecordingReadyScreen( + uiState: PracticeRecordingUiState, + onStartRecording: () -> Unit, + onStopRecording: () -> Unit, + onStartPlayback: () -> Unit, + onStopPlayback: () -> Unit, + onClickAnalyze: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val analyzeLabel = stringResource(R.string.feature_home_impl_practice_recording_analyze) + + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + PracticeRecordingTopAppBar(onBack = onBack) + + PracticeRecordingContent( + practiceScript = uiState.practiceScript, + currentSeconds = uiState.currentSeconds, + totalSeconds = uiState.totalSeconds, + recordingState = uiState.recordingState, + onStartRecording = onStartRecording, + onStopRecording = onStopRecording, + onStartPlayback = onStartPlayback, + onStopPlayback = onStopPlayback, + modifier = Modifier.weight(1f), + ) + + PrezelButtonArea( + mainButton = { buttonModifier -> + PrezelButton( + text = analyzeLabel, + modifier = buttonModifier, + enabled = uiState.analyzeEnabled, + onClick = onClickAnalyze, + ) + }, + ) + } +} + +private val PracticeRecordingUiMessage.resId: Int + get() = when (this) { + PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED -> R.string.feature_home_impl_practice_recording_fetch_script_failed + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> R.string.feature_home_impl_practice_recording_permission_denied + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED -> + R.string.feature_home_impl_practice_recording_permission_permanently_denied + + PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_home_impl_practice_recording_failed + PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_home_impl_practice_recording_stop_failed + PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_home_impl_practice_recording_playback_failed + } + +@BasicPreview +@Composable +private fun PracticeRecordingScreenIdlePreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent(uiState = PracticeRecordingUiState()) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingScreenRecordingPreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent( + uiState = PracticeRecordingUiState( + recordingState = AudioSessionState.Recording( + elapsedSeconds = 12, + ), + ), + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingScreenRecordedPreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent( + uiState = PracticeRecordingUiState( + recordingState = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = ""), + durationSeconds = 32, + ), + ), + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingScreenPlayingPreview() { + PrezelTheme { + PracticeRecordingScreenPreviewContent( + uiState = PracticeRecordingUiState( + recordingState = AudioSessionState.Playing( + source = AudioSource.RecordedFile(filePath = ""), + positionSeconds = 12, + durationSeconds = 32, + ), + ), + ) + } +} + +@Composable +private fun PracticeRecordingScreenPreviewContent(uiState: PracticeRecordingUiState) { + PracticeRecordingScreen( + uiState = uiState.copy( + practiceScript = "내가 그린 기린 그림은 잘 그린 기린 그림이고,\n네가 그린 기린 그림은 잘못 그린 기린 그림이다.", + ), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + onClickAnalyze = {}, + onRetryRecording = {}, + onBack = {}, + navigateToHome = {}, + ) +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt new file mode 100644 index 00000000..def9f03d --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.kt @@ -0,0 +1,152 @@ +package com.team.prezel.feature.home.impl.practice + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.audio.AudioSessionEffect +import com.team.prezel.core.audio.RecordingAudioController +import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase +import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiEffect +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.home.impl.practice.contract.PracticeRecordingUiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisUiModel +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class PracticeRecordingViewModel @Inject constructor( + private val audioController: RecordingAudioController, + private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, + private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, +) : BaseViewModel(PracticeRecordingUiState()) { + init { + collectAudioSessionState() + collectAudioSessionEffect() + } + + override fun onIntent(intent: PracticeRecordingUiIntent) { + when (intent) { + PracticeRecordingUiIntent.LoadPracticeScript -> fetchPracticeScript() + PracticeRecordingUiIntent.RecordAudioPermissionDenied -> showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) + PracticeRecordingUiIntent.RecordAudioPermissionPermanentlyDenied -> showMessage( + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, + ) + + PracticeRecordingUiIntent.StartRecording -> { + updateState { copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) } + audioController.startRecording() + } + + PracticeRecordingUiIntent.StopRecording -> audioController.stopRecording() + PracticeRecordingUiIntent.StartPlayback -> audioController.startPlayback() + PracticeRecordingUiIntent.StopPlayback -> audioController.stopPlayback() + PracticeRecordingUiIntent.AnalyzeClicked -> startAnalysis() + PracticeRecordingUiIntent.RetryRecordingClicked -> resetPracticeRecording() + } + } + + private fun collectAudioSessionState() { + viewModelScope.launch { + audioController.audioSessionState.collect { audioState -> + updateState { + copy(recordingState = audioState) + } + } + } + } + + private fun collectAudioSessionEffect() { + viewModelScope.launch { + audioController.audioSessionEffect.collect { effect -> + showMessage(effect.toUiMessage()) + } + } + } + + private fun fetchPracticeScript() { + viewModelScope.launch { + fetchPracticeScriptUseCase() + .onSuccess { script -> + updateState { + copy(practiceScript = script.content) + } + }.onFailure { + showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) + } + } + } + + private fun startAnalysis() { + if (!currentState.analyzeEnabled) return + val filePath = currentState.recordingFilePath ?: return + + audioController.stopPlayback() + viewModelScope.launch { + updateState { + copy(analysisStatus = PracticeRecordingAnalysisStatus.Loading) + } + + delay(ANALYSIS_LOADING_DELAY_MILLIS) + + analyzePracticeRecordingUseCase(recordingFilePath = filePath) + .onSuccess { result -> + updateState { + copy( + analysisStatus = PracticeRecordingAnalysisStatus.Success( + result = result.toUiModel(), + ), + ) + } + }.onFailure { + updateState { + copy( + analysisStatus = PracticeRecordingAnalysisStatus.Error( + type = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, + ), + ) + } + } + } + } + + private fun resetPracticeRecording() { + audioController.reset() + updateState { + copy(analysisStatus = PracticeRecordingAnalysisStatus.Ready) + } + } + + private fun showMessage(message: PracticeRecordingUiMessage) { + viewModelScope.launch { + sendEffect(PracticeRecordingUiEffect.ShowMessage(message)) + } + } + + override fun onCleared() { + audioController.release() + super.onCleared() + } + + private companion object { + const val ANALYSIS_LOADING_DELAY_MILLIS = 3_000L + } +} + +private fun AudioSessionEffect.toUiMessage(): PracticeRecordingUiMessage = + when (this) { + AudioSessionEffect.RecordingStartFailed -> PracticeRecordingUiMessage.RECORDING_START_FAILED + AudioSessionEffect.RecordingStopFailed -> PracticeRecordingUiMessage.RECORDING_STOP_FAILED + AudioSessionEffect.PlaybackStartFailed -> PracticeRecordingUiMessage.PLAYBACK_START_FAILED + } + +private fun PracticeRecordingAnalysisResult.toUiModel(): PracticeRecordingAnalysisUiModel = + PracticeRecordingAnalysisUiModel( + pronunciationScore = pronunciationScore, + speed = speed, + ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt new file mode 100644 index 00000000..561af186 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt @@ -0,0 +1,38 @@ +package com.team.prezel.feature.home.impl.practice + +import android.Manifest +import androidx.compose.runtime.Composable +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.ui.util.rememberPermissionRequest + +@Composable +internal fun rememberRecordAudioPermissionControlClickHandler( + recordingState: AudioSessionState, + onStartRecording: () -> Unit, + onPermissionDenied: () -> Unit, + onPermissionPermanentlyDenied: () -> Unit, +): () -> Unit { + val permissionRequest = rememberPermissionRequest( + permission = Manifest.permission.RECORD_AUDIO, + onPermissionGranted = onStartRecording, + onPermissionDenied = onPermissionDenied, + onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, + ) + + return { + when (recordingState) { + AudioSessionState.Idle -> { + when { + permissionRequest.isGranted -> onStartRecording() + permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() + else -> permissionRequest.launch() + } + } + + is AudioSessionState.Recording, + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> Unit + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt new file mode 100644 index 00000000..591bab34 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt @@ -0,0 +1,127 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.home.impl.R + +@Composable +internal fun PracticeRecordingContent( + practiceScript: String, + currentSeconds: Int, + totalSeconds: Int, + recordingState: AudioSessionState, + onStartRecording: () -> Unit, + onStopRecording: () -> Unit, + onStartPlayback: () -> Unit, + onStopPlayback: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20, vertical = PrezelTheme.spacing.V16), + ) { + Text( + text = stringResource(R.string.feature_home_impl_practice_recording_instruction), + style = PrezelTheme.typography.title2Bold, + color = PrezelTheme.colors.textLarge, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(PrezelTheme.radius.V6)) + .background(PrezelTheme.colors.bgMedium) + .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), + contentAlignment = Alignment.Center, + ) { + Text( + text = practiceScript, + style = PrezelTheme.typography.body2Regular, + color = when (recordingState) { + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> PrezelTheme.colors.textDisabled + + AudioSessionState.Idle, + is AudioSessionState.Recording, + -> PrezelTheme.colors.textLarge + }, + textAlign = TextAlign.Center, + ) + } + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + PracticeRecordingControl( + currentSeconds = currentSeconds, + totalSeconds = totalSeconds, + audioSessionState = recordingState, + onStartRecording = onStartRecording, + onStopRecording = onStopRecording, + onStartPlayback = onStartPlayback, + onStopPlayback = onStopPlayback, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingContentReadyToRecordPreview() { + PrezelTheme { + PracticeRecordingContent( + practiceScript = "안녕하세요. 오늘은 제가 준비한 발표 연습을 시작해보겠습니다.", + currentSeconds = 0, + totalSeconds = 0, + recordingState = AudioSessionState.Idle, + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + modifier = Modifier.height(520.dp), + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingContentReadyToPlayPreview() { + PrezelTheme { + PracticeRecordingContent( + practiceScript = "안녕하세요. 오늘은 제가 준비한 발표 연습을 시작해보겠습니다.", + currentSeconds = 12, + totalSeconds = 45, + recordingState = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 12, + durationSeconds = 45, + ), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + modifier = Modifier.height(520.dp), + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt new file mode 100644 index 00000000..ab7efe61 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt @@ -0,0 +1,237 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.AudioSource +import com.team.prezel.core.designsystem.component.actions.button.PrezelIconButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.component.actions.button.config.PrezelButtonDefaults +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun PracticeRecordingControl( + currentSeconds: Int, + totalSeconds: Int, + audioSessionState: AudioSessionState, + onStartRecording: () -> Unit, + onStopRecording: () -> Unit, + onStartPlayback: () -> Unit, + onStopPlayback: () -> Unit, + modifier: Modifier = Modifier, +) { + val actions = audioSessionState.actions( + onStartRecording = onStartRecording, + onStopRecording = onStopRecording, + onStartPlayback = onStartPlayback, + onStopPlayback = onStopPlayback, + ) + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PracticeRecordingTimeText( + currentSeconds = currentSeconds, + totalSeconds = totalSeconds, + audioSessionState = audioSessionState, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V8), + verticalAlignment = Alignment.CenterVertically, + ) { + actions.forEach { action -> + PrezelIconButton( + iconResId = action.iconResId, + modifier = Modifier.size(48.dp), + isRounded = true, + buttonDefault = PrezelButtonDefaults.getDefault( + isIconOnly = true, + isRounded = true, + type = ButtonType.FILLED, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.SECONDARY, + contentColor = action.iconColor(), + backgroundColor = PrezelTheme.colors.bgLarge, + iconSize = 20.dp, + ), + onClick = action.onClick, + ) + } + } + } +} + +@Composable +private fun PracticeRecordingTimeText( + currentSeconds: Int, + totalSeconds: Int, + audioSessionState: AudioSessionState, +) { + if (audioSessionState == AudioSessionState.Idle || audioSessionState is AudioSessionState.Recording) { + Text( + text = currentSeconds.toTimerText(), + style = PrezelTheme.typography.title1Medium, + color = PrezelTheme.colors.textMedium, + ) + return + } + + val currentColor = PrezelTheme.colors.interactiveRegular + val totalColor = PrezelTheme.colors.textSmall + + Text( + text = buildAnnotatedString { + withStyle(SpanStyle(color = currentColor)) { + append(currentSeconds.toTimerText()) + } + withStyle(SpanStyle(color = totalColor)) { + append("/") + append(totalSeconds.toTimerText()) + } + }, + style = PrezelTheme.typography.title1Medium, + ) +} + +private data class PracticeRecordingControlAction( + @param:DrawableRes val iconResId: Int, + val colorType: PracticeRecordingControlActionColorType, + val onClick: () -> Unit, +) + +private enum class PracticeRecordingControlActionColorType { + RECORD, + REGULAR, +} + +private fun AudioSessionState.actions( + onStartRecording: () -> Unit, + onStopRecording: () -> Unit, + onStartPlayback: () -> Unit, + onStopPlayback: () -> Unit, +): List = + when (this) { + AudioSessionState.Idle -> listOf( + PracticeRecordingControlAction( + iconResId = PrezelIcons.Recording, + colorType = PracticeRecordingControlActionColorType.RECORD, + onClick = onStartRecording, + ), + ) + + is AudioSessionState.Recording -> stopAction(onStop = onStopRecording) + + is AudioSessionState.ReadyToPlay -> listOf( + PracticeRecordingControlAction( + iconResId = PrezelIcons.Play, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onStartPlayback, + ), + ) + + is AudioSessionState.Playing -> stopAction(onStop = onStopPlayback) + } + +private fun stopAction(onStop: () -> Unit): List = + listOf( + PracticeRecordingControlAction( + iconResId = PrezelIcons.Stop, + colorType = PracticeRecordingControlActionColorType.REGULAR, + onClick = onStop, + ), + ) + +@Composable +private fun PracticeRecordingControlAction.iconColor() = + when (colorType) { + PracticeRecordingControlActionColorType.RECORD -> PrezelTheme.colors.feedbackBadRegular + PracticeRecordingControlActionColorType.REGULAR -> PrezelTheme.colors.iconRegular + } + +private fun Int.toTimerText(): String { + val minutes = this / 60 + val seconds = this % 60 + return "%02d:%02d".format(minutes, seconds) +} + +@BasicPreview +@Composable +private fun PracticeRecordingControlPreview() { + PrezelTheme { + Column( + modifier = Modifier + .background(PrezelTheme.colors.bgRegular) + .padding(PrezelTheme.spacing.V20), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V20), + ) { + PracticeRecordingControl( + currentSeconds = 0, + totalSeconds = 0, + audioSessionState = AudioSessionState.Idle, + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + + PracticeRecordingControl( + currentSeconds = 8, + totalSeconds = 0, + audioSessionState = AudioSessionState.Recording(elapsedSeconds = 8), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + + PracticeRecordingControl( + currentSeconds = 16, + totalSeconds = 45, + audioSessionState = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 16, + durationSeconds = 45, + ), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + + PracticeRecordingControl( + currentSeconds = 24, + totalSeconds = 45, + audioSessionState = AudioSessionState.Playing( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 24, + durationSeconds = 45, + ), + onStartRecording = {}, + onStopRecording = {}, + onStartPlayback = {}, + onStopPlayback = {}, + ) + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt new file mode 100644 index 00000000..6c113b14 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.kt @@ -0,0 +1,38 @@ +package com.team.prezel.feature.home.impl.practice.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.home.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PracticeRecordingTopAppBar(onBack: () -> Unit) { + PrezelTopAppBar( + title = { Text(text = stringResource(R.string.feature_home_impl_practice_recording_title)) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_home_impl_practice_recording_back), + ) + } + }, + ) +} + +@BasicPreview +@Composable +private fun PracticeRecordingTopAppBarPreview() { + PrezelTheme { + PracticeRecordingTopAppBar(onBack = {}) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt new file mode 100644 index 00000000..43fe18b5 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.home.impl.practice.contract + +import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingUiMessage + +internal sealed interface PracticeRecordingUiEffect : UiEffect { + data class ShowMessage( + val message: PracticeRecordingUiMessage, + ) : PracticeRecordingUiEffect +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt new file mode 100644 index 00000000..e8fa26fa --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt @@ -0,0 +1,23 @@ +package com.team.prezel.feature.home.impl.practice.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface PracticeRecordingUiIntent : UiIntent { + data object LoadPracticeScript : PracticeRecordingUiIntent + + data object RecordAudioPermissionDenied : PracticeRecordingUiIntent + + data object RecordAudioPermissionPermanentlyDenied : PracticeRecordingUiIntent + + data object StartRecording : PracticeRecordingUiIntent + + data object StopRecording : PracticeRecordingUiIntent + + data object StartPlayback : PracticeRecordingUiIntent + + data object StopPlayback : PracticeRecordingUiIntent + + data object AnalyzeClicked : PracticeRecordingUiIntent + + data object RetryRecordingClicked : PracticeRecordingUiIntent +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt new file mode 100644 index 00000000..de015c13 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.kt @@ -0,0 +1,42 @@ +package com.team.prezel.feature.home.impl.practice.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus + +@Immutable +internal data class PracticeRecordingUiState( + val practiceScript: String = "", + val recordingState: AudioSessionState = AudioSessionState.Idle, + val analysisStatus: PracticeRecordingAnalysisStatus = PracticeRecordingAnalysisStatus.Ready, +) : UiState { + val currentSeconds: Int + get() = when (val state = recordingState) { + AudioSessionState.Idle -> 0 + is AudioSessionState.Recording -> state.elapsedSeconds + is AudioSessionState.ReadyToPlay -> state.positionSeconds + is AudioSessionState.Playing -> state.positionSeconds + } + + val totalSeconds: Int + get() = when (val state = recordingState) { + AudioSessionState.Idle, + is AudioSessionState.Recording, + -> 0 + + is AudioSessionState.ReadyToPlay -> state.durationSeconds + is AudioSessionState.Playing -> state.durationSeconds + } + + val recordingFilePath: String? + get() = when (val state = recordingState) { + is AudioSessionState.ReadyToPlay -> state.source.filePath + is AudioSessionState.Playing -> state.source.filePath + else -> null + } + + val analyzeEnabled: Boolean + get() = recordingState is AudioSessionState.ReadyToPlay && + analysisStatus !is PracticeRecordingAnalysisStatus.Loading +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt new file mode 100644 index 00000000..5e55040f --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt @@ -0,0 +1,23 @@ +package com.team.prezel.feature.home.impl.practice.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface PracticeRecordingAnalysisStatus { + data object Ready : PracticeRecordingAnalysisStatus + + data object Loading : PracticeRecordingAnalysisStatus + + data class Success( + val result: PracticeRecordingAnalysisUiModel, + ) : PracticeRecordingAnalysisStatus + + data class Error( + val type: PracticeRecordingAnalysisErrorType, + ) : PracticeRecordingAnalysisStatus +} + +internal enum class PracticeRecordingAnalysisErrorType { + VOICE_RECOGNITION_FAILED, + ANALYSIS_FAILED, +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt new file mode 100644 index 00000000..60ad1d55 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.home.impl.practice.model + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.practice.PracticeRecordingSpeed + +@Immutable +internal data class PracticeRecordingAnalysisUiModel( + val pronunciationScore: Int, + val speed: PracticeRecordingSpeed, +) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt new file mode 100644 index 00000000..619c4120 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.home.impl.practice.model + +internal enum class PracticeRecordingUiMessage { + FETCH_PRACTICE_SCRIPT_FAILED, + RECORD_AUDIO_PERMISSION_DENIED, + RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED, + RECORDING_START_FAILED, + RECORDING_STOP_FAILED, + PLAYBACK_START_FAILED, +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt new file mode 100644 index 00000000..86b10684 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt @@ -0,0 +1,34 @@ +package com.team.prezel.feature.home.impl.practice.result + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisStatus +import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisFailurePage +import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingAnalysisLoadingPage +import com.team.prezel.feature.home.impl.practice.result.component.PracticeRecordingResultPage + +@Composable +internal fun PracticeRecordingResultScreen( + analysisStatus: PracticeRecordingAnalysisStatus, + onRetry: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + when (analysisStatus) { + PracticeRecordingAnalysisStatus.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) + is PracticeRecordingAnalysisStatus.Success -> PracticeRecordingResultPage( + pronunciationScore = analysisStatus.result.pronunciationScore, + speed = analysisStatus.result.speed, + onComplete = onComplete, + modifier = modifier, + ) + + is PracticeRecordingAnalysisStatus.Error -> PracticeRecordingAnalysisFailurePage( + errorType = analysisStatus.type, + onRetry = onRetry, + modifier = modifier, + ) + + PracticeRecordingAnalysisStatus.Ready -> Unit + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt new file mode 100644 index 00000000..e216a03b --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt @@ -0,0 +1,81 @@ +package com.team.prezel.feature.home.impl.practice.result.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize +import com.team.prezel.core.designsystem.component.actions.button.config.ButtonType +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.component.StatusView +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.practice.model.PracticeRecordingAnalysisErrorType +import com.team.prezel.core.ui.R as CoreUiR + +@Composable +internal fun PracticeRecordingAnalysisFailurePage( + errorType: PracticeRecordingAnalysisErrorType, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + StatusView( + title = stringResource(R.string.feature_home_impl_practice_recording_analysis_error_title), + description = stringResource(R.string.feature_home_impl_practice_recording_analysis_error_description), + modifier = modifier, + visual = { + Image( + painter = painterResource(errorType.drawableResId), + contentDescription = null, + modifier = Modifier.size(120.dp), + ) + }, + action = { + PrezelButton( + text = stringResource(R.string.feature_home_impl_practice_recording_analysis_retry), + iconResId = PrezelIcons.Reset, + type = ButtonType.FILLED, + size = ButtonSize.SMALL, + hierarchy = ButtonHierarchy.SECONDARY, + isRounded = true, + onClick = onRetry, + ) + }, + ) +} + +private val PracticeRecordingAnalysisErrorType.drawableResId: Int + @DrawableRes + get() = when (this) { + PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED -> CoreUiR.drawable.core_ui_error_analyze + PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED -> CoreUiR.drawable.core_ui_error_voice + } + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisAnalyzeFailurePagePreview() { + PrezelTheme { + PracticeRecordingAnalysisFailurePage( + errorType = PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED, + onRetry = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisVoiceFailurePagePreview() { + PrezelTheme { + PracticeRecordingAnalysisFailurePage( + errorType = PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED, + onRetry = {}, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt new file mode 100644 index 00000000..cae12fb4 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.kt @@ -0,0 +1,36 @@ +package com.team.prezel.feature.home.impl.practice.result.component + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.component.PrezelLottie +import com.team.prezel.core.ui.component.StatusView +import com.team.prezel.feature.home.impl.R +import com.team.prezel.core.ui.R as CoreUiR + +@Composable +internal fun PracticeRecordingAnalysisLoadingPage(modifier: Modifier = Modifier) { + StatusView( + title = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_title), + description = stringResource(R.string.feature_home_impl_practice_recording_analysis_loading_description), + modifier = modifier, + visual = { + PrezelLottie( + resId = CoreUiR.raw.core_ui_asset_loading, + modifier = Modifier.size(80.dp), + ) + }, + ) +} + +@BasicPreview +@Composable +private fun PracticeRecordingAnalysisLoadingPagePreview() { + PrezelTheme { + PracticeRecordingAnalysisLoadingPage() + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt new file mode 100644 index 00000000..e7e028f1 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.kt @@ -0,0 +1,257 @@ +package com.team.prezel.feature.home.impl.practice.result.component + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.component.chip.PrezelChip +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipDefaults +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipSize +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipType +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.feature.home.impl.R + +private enum class PracticeAnalysisOverallResult( + @param:StringRes val contentDescriptionResId: Int, + @param:DrawableRes val cardResId: Int, +) { + PERFECT( + contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_perfect, + cardResId = R.drawable.feature_home_impl_card_perfect, + ), + GOOD( + contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_good, + cardResId = R.drawable.feature_home_impl_card_good, + ), + TRY( + contentDescriptionResId = R.string.feature_home_impl_practice_recording_analysis_card_try, + cardResId = R.drawable.feature_home_impl_card_try, + ), +} + +@Composable +internal fun PracticeRecordingResultPage( + pronunciationScore: Int, + speed: PracticeRecordingSpeed, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val overallResult = rememberOverallResult( + pronunciationScore = pronunciationScore, + speed = speed, + ) + + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + PracticeRecordingResultContent( + cardResId = overallResult.cardResId, + cardContentDescription = stringResource(overallResult.contentDescriptionResId), + pronunciationScore = pronunciationScore, + speed = speed, + modifier = Modifier.weight(1f), + ) + + PracticeRecordingResultButtonArea(onComplete = onComplete) + } +} + +@Composable +private fun PracticeRecordingResultContent( + @DrawableRes cardResId: Int, + cardContentDescription: String, + pronunciationScore: Int, + speed: PracticeRecordingSpeed, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(cardResId), + contentDescription = cardContentDescription, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentScale = ContentScale.Fit, + ) + + PracticeAnalysisMetricRow( + pronunciationScore = pronunciationScore, + speed = speed, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20, vertical = PrezelTheme.spacing.V16), + ) + } +} + +@Composable +private fun PracticeRecordingResultButtonArea( + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + val completeLabel = stringResource(R.string.feature_home_impl_practice_recording_analysis_complete) + + PrezelButtonArea( + modifier = modifier, + mainButton = { buttonModifier -> + PrezelButton( + text = completeLabel, + modifier = buttonModifier, + enabled = true, + onClick = onComplete, + ) + }, + ) +} + +private fun rememberOverallResult( + pronunciationScore: Int, + speed: PracticeRecordingSpeed, +): PracticeAnalysisOverallResult = + when { + pronunciationScore >= 95 && speed == PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.PERFECT + pronunciationScore >= 70 && speed == PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD + pronunciationScore >= 95 && speed != PracticeRecordingSpeed.ADEQUATE -> PracticeAnalysisOverallResult.GOOD + pronunciationScore <= 60 -> PracticeAnalysisOverallResult.TRY + else -> PracticeAnalysisOverallResult.TRY + } + +@Composable +private fun PracticeAnalysisMetricRow( + pronunciationScore: Int, + speed: PracticeRecordingSpeed, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f).padding(start = 8.dp, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_home_impl_practice_recording_analysis_pronunciation)) + Text( + text = "$pronunciationScore%", + style = PrezelTheme.typography.body1Medium, + color = PrezelTheme.colors.textLarge, + ) + } + + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V12)) + + Box( + modifier = Modifier + .width(1.dp) + .height(24.dp) + .background(PrezelTheme.colors.borderRegular), + ) + + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V12)) + + Row( + modifier = Modifier.weight(1f).padding(end = 8.dp, top = 5.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PracticeAnalysisMetricLabel(text = stringResource(R.string.feature_home_impl_practice_recording_analysis_speed)) + PrezelChip( + text = stringResource(speed.labelResId), + modifier = Modifier, + config = PrezelChipDefaults.getDefault( + iconOnly = false, + type = PrezelChipType.FILLED, + size = PrezelChipSize.REGULAR, + textStyle = PrezelTheme.typography.caption1Medium, + containerColor = PrezelTheme.colors.bgLarge, + textColor = PrezelTheme.colors.textMedium, + ), + ) + } + } +} + +private val PracticeRecordingSpeed.labelResId: Int + @StringRes + get() = when (this) { + PracticeRecordingSpeed.SLOW -> R.string.feature_home_impl_practice_recording_analysis_speed_slow + PracticeRecordingSpeed.ADEQUATE -> R.string.feature_home_impl_practice_recording_analysis_speed_adequate + PracticeRecordingSpeed.FAST -> R.string.feature_home_impl_practice_recording_analysis_speed_fast + } + +@Composable +private fun PracticeAnalysisMetricLabel( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = PrezelTheme.typography.caption1Medium, + color = PrezelTheme.colors.textMedium, + modifier = modifier, + ) +} + +@BasicPreview +@Composable +private fun PracticeRecordingResultPerfectPagePreview() { + PrezelTheme { + PracticeRecordingResultPage( + pronunciationScore = 96, + speed = PracticeRecordingSpeed.ADEQUATE, + onComplete = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingResultGoodPagePreview() { + PrezelTheme { + PracticeRecordingResultPage( + pronunciationScore = 90, + speed = PracticeRecordingSpeed.ADEQUATE, + onComplete = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingResultTryPagePreview() { + PrezelTheme { + PracticeRecordingResultPage( + pronunciationScore = 58, + speed = PracticeRecordingSpeed.FAST, + onComplete = {}, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml new file mode 100644 index 00000000..70de122f --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml new file mode 100644 index 00000000..d1c29065 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml new file mode 100644 index 00000000..161c39cc --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 140c2c8b..8932898a 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -18,6 +18,33 @@ 학술·교육 업무·보고 + + 연습하기 + 연습 녹음 + 뒤로가기 + 아래 문장을 소리내어 읽어주세요. + 분석하기 + 대본을 불러오지 못했습니다. + 마이크 권한이 필요합니다. + 설정에서 마이크 권한을 허용해 주세요. + 녹음을 시작하지 못했습니다. + 녹음을 저장하지 못했습니다. + 녹음을 재생하지 못했습니다. + 분석중 + 잠시만 기다려주세요 + 분석에 실패했어요 + 음성이 작거나 주변 소음이 많았을 수 있어요.\n조용한 환경에서 다시 시도해 주세요. + 다시 시도하기 + 완료 + 발화 + 속도 + 느려요 + 적당해요 + 빨라요 + perfect + good + try + 데이터를 불러오지 못했습니다. diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 2529bd56..695cab9c 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -42,6 +42,7 @@ androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "life androidx-lifecycle-runtimeTesting = { group = "androidx.lifecycle", name = "lifecycle-runtime-testing", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 825400ea..1badd04e 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -39,6 +39,7 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") includeAuto( ":app", + ":core:audio", ":core:auth", ":core:common", ":core:data",