diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index e99a933f..5cd625c3 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -42,15 +42,15 @@ dependencies { implementation(projects.featureSplashImpl) implementation(projects.featureLoginApi) implementation(projects.featureLoginImpl) - implementation(projects.featureTermsApi) - implementation(projects.featureTermsImpl) implementation(projects.featureHomeApi) implementation(projects.featureHomeImpl) implementation(projects.featureHistoryApi) implementation(projects.featureHistoryImpl) implementation(projects.featureMyApi) implementation(projects.featureMyImpl) - implementation(projects.featureSettingApi) + + implementation(projects.featureTermsImpl) + implementation(projects.featurePracticeImpl) implementation(projects.featureSettingImpl) implementation(projects.featureProfileImpl) 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 @@ + Unit, + ): Result + + fun currentPositionSeconds(): Int + + fun release() +} diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt new file mode 100644 index 00000000..dd05183f --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioRecorderSession.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.audio + +internal interface AudioRecorderSession { + fun start(): Result + + fun stop(elapsedSeconds: Int): Result + + fun reset() +} diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionEffect.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionEffect.kt new file mode 100644 index 00000000..60dac9e3 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionEffect.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.audio + +sealed interface AudioSessionEffect { + data object RecordingStartFailed : AudioSessionEffect + + data object RecordingStopFailed : AudioSessionEffect + + data object PlaybackStartFailed : AudioSessionEffect +} diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt new file mode 100644 index 00000000..4f47208c --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/AudioSessionState.kt @@ -0,0 +1,33 @@ +package com.team.prezel.core.audio + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface AudioSessionState { + data object Idle : AudioSessionState + + data class Recording( + val elapsedSeconds: Int, + ) : AudioSessionState + + data class ReadyToPlay( + val source: AudioSource, + val positionSeconds: Int = 0, + val durationSeconds: Int, + ) : AudioSessionState + + data class Playing( + val source: AudioSource, + val positionSeconds: Int, + val durationSeconds: Int, + ) : AudioSessionState +} + +@Immutable +sealed interface AudioSource { + val filePath: String + + data class RecordedFile( + override val filePath: String, + ) : AudioSource +} diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt new file mode 100644 index 00000000..5bc1c184 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaPlayerSession.kt @@ -0,0 +1,56 @@ +package com.team.prezel.core.audio + +import android.media.MediaPlayer +import javax.inject.Inject + +internal class MediaPlayerSession @Inject constructor() : AudioPlayerSession { + private var player: MediaPlayer? = null + + override fun start( + source: AudioSource, + startPositionSeconds: Int, + onCompleted: () -> Unit, + ): Result = + runCatching { + release() + + var pendingPlayer: MediaPlayer? = null + val newPlayer = runCatching { + MediaPlayer().also { pendingPlayer = it }.apply { + setDataSource(source.filePath) + prepare() + seekToStartPosition(startPositionSeconds) + setOnCompletionListener { onCompleted() } + start() + } + }.getOrElse { throwable -> + pendingPlayer?.release() + throw throwable + } + + player = newPlayer + newPlayer.duration + }.onFailure { + release() + } + + override fun currentPositionSeconds(): Int = player?.currentPosition?.toSeconds() ?: 0 + + override fun release() { + player?.runCatching { stop() } + player?.release() + player = null + } + + private fun MediaPlayer.seekToStartPosition(startPositionSeconds: Int) { + if (startPositionSeconds > 0) { + seekTo(startPositionSeconds * MILLIS_PER_SECOND) + } + } + + private companion object { + const val MILLIS_PER_SECOND = 1_000 + } +} + +internal fun Int.toSeconds(): Int = this / 1_000 diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt new file mode 100644 index 00000000..e715d3ea --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecorderSession.kt @@ -0,0 +1,81 @@ +package com.team.prezel.core.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import kotlin.math.max + +internal class MediaRecorderSession @Inject constructor( + @param:ApplicationContext private val context: Context, +) : AudioRecorderSession { + private var recorder: MediaRecorder? = null + private var currentAudioFile: File? = null + + override fun start(): Result = + runCatching { + reset() + + val file = File.createTempFile("recording_", ".m4a", context.cacheDir) + var pendingRecorder: MediaRecorder? = null + val newRecorder = runCatching { + createMediaRecorder(context = context).also { pendingRecorder = it }.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 + }.onFailure { + reset() + } + + override fun stop(elapsedSeconds: Int): Result = + runCatching { + val file = currentAudioFile!! + recorder!!.stop() + RecordedAudio( + source = AudioSource.RecordedFile(filePath = file.absolutePath), + durationSeconds = max(elapsedSeconds, 0), + ) + }.onSuccess { + releaseRecorder() + }.onFailure { + reset() + } + + override fun reset() { + releaseRecorder() + currentAudioFile?.delete() + currentAudioFile = null + } + + private fun releaseRecorder() { + recorder?.release() + recorder = null + } +} + +internal data class RecordedAudio( + val source: AudioSource.RecordedFile, + val durationSeconds: Int, +) + +@Suppress("DEPRECATION") +private fun createMediaRecorder(context: Context): MediaRecorder = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + MediaRecorder(context) + } else { + MediaRecorder() + } diff --git a/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt new file mode 100644 index 00000000..4e74fdda --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt @@ -0,0 +1,209 @@ +package com.team.prezel.core.audio + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class MediaRecordingAudioController @Inject constructor( + private val recorderSession: AudioRecorderSession, + private val playerSession: AudioPlayerSession, +) : RecordingAudioController { + private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val _audioSessionState = MutableStateFlow(AudioSessionState.Idle) + override val audioSessionState: StateFlow = _audioSessionState.asStateFlow() + + private val _audioSessionEffect = Channel(capacity = Channel.BUFFERED) + override val audioSessionEffect: Flow = _audioSessionEffect.receiveAsFlow() + + private var recordingTimerJob: Job? = null + private var playbackTimerJob: Job? = null + + override fun startRecording() { + runCatching { + stopPlayback() + recorderSession.start().getOrThrow() + _audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = 0) + startRecordingTimer() + }.onFailure { + recorderSession.reset() + _audioSessionState.value = AudioSessionState.Idle + emitEffect(AudioSessionEffect.RecordingStartFailed) + } + } + + override fun stopRecording() { + val elapsedSeconds = when (val state = audioSessionState.value) { + is AudioSessionState.Recording -> state.elapsedSeconds + else -> return + } + + recordingTimerJob?.cancel() + recorderSession + .stop(elapsedSeconds = elapsedSeconds) + .onSuccess { recordedAudio -> + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = recordedAudio.source, + durationSeconds = recordedAudio.durationSeconds, + ) + }.onFailure { + _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 = playerSession + .currentPositionSeconds() + .coerceAtLeast(state.positionSeconds), + durationSeconds = state.durationSeconds, + ) + + else -> null + } + + releasePlayer() + if (readyState != null) { + _audioSessionState.value = readyState + } + } + + override fun reset() { + recordingTimerJob?.cancel() + playbackTimerJob?.cancel() + recorderSession.reset() + releasePlayer() + _audioSessionState.value = AudioSessionState.Idle + } + + override fun release() { + reset() + controllerScope.cancel() + } + + private fun startPlayback( + source: AudioSource, + durationSeconds: Int, + startPositionSeconds: Int, + ) { + playerSession + .start( + source = source, + startPositionSeconds = startPositionSeconds, + onCompleted = { + handlePlaybackCompleted( + source = source, + durationSeconds = durationSeconds, + ) + }, + ).onSuccess { playerDurationMillis -> + _audioSessionState.value = AudioSessionState.Playing( + source = source, + positionSeconds = startPositionSeconds, + durationSeconds = durationSeconds.coerceAtLeast(playerDurationMillis.toSeconds()), + ) + startPlaybackTimer() + }.onFailure { + handlePlaybackStartFailure( + source = source, + durationSeconds = durationSeconds, + ) + } + } + + private fun handlePlaybackCompleted( + source: AudioSource, + durationSeconds: Int, + ) { + releasePlayer() + _audioSessionState.value = AudioSessionState.ReadyToPlay( + source = source, + positionSeconds = durationSeconds, + durationSeconds = durationSeconds, + ) + } + + private fun handlePlaybackStartFailure( + source: AudioSource, + durationSeconds: Int, + ) { + 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 = playerSession.currentPositionSeconds(), + durationSeconds = state.durationSeconds, + ) + } + } + } + } + + private fun releasePlayer() { + playbackTimerJob?.cancel() + playerSession.release() + } + + private fun emitEffect(effect: AudioSessionEffect) { + _audioSessionEffect.trySend(effect) + } + + private companion object { + const val RECORDING_TIMER_DELAY_MILLIS = 1_000L + const val PLAYBACK_TIMER_DELAY_MILLIS = 250L + } +} 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..77dfe070 --- /dev/null +++ b/Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.kt @@ -0,0 +1,23 @@ +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 + + @Binds + @ViewModelScoped + abstract fun bindAudioRecorderSession(impl: MediaRecorderSession): AudioRecorderSession + + @Binds + @ViewModelScoped + abstract fun bindAudioPlayerSession(impl: MediaPlayerSession): AudioPlayerSession +} diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt index 9e707147..197bfbac 100644 --- a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/error/AppError.kt @@ -6,6 +6,7 @@ enum class AppError { SERVER_ERROR, NOT_FOUND, DUPLICATE, + VOICE_RECOGNITION_FAILED, NETWORK, UNKNOWN, } 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..4b74d1ab 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 @@ -1,9 +1,11 @@ package com.team.prezel.core.data.di import com.team.prezel.core.data.repository.AuthRepositoryImpl +import com.team.prezel.core.data.repository.PracticeRepositoryImpl import com.team.prezel.core.data.repository.TermsRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl 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/error/AppErrorExt.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt index 0e726ffa..72b731d3 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/error/AppErrorExt.kt @@ -45,9 +45,11 @@ private fun ServerErrorCode.toDomainError(): AppError = ServerErrorCode.SERVER_ERROR, ServerErrorCode.FILE_UPLOAD_FAILED, + ServerErrorCode.VOICE_ANALYSIS_FAILED, -> AppError.SERVER_ERROR ServerErrorCode.TERMS_NOT_FOUND, + ServerErrorCode.SENTENCE_NOT_FOUND, -> AppError.NOT_FOUND ServerErrorCode.DUPLICATE_NICKNAME -> AppError.DUPLICATE @@ -60,5 +62,7 @@ private fun ServerErrorCode.toDomainError(): AppError = ServerErrorCode.INVALID_ID_TOKEN, -> AppError.UNAUTHORIZED + ServerErrorCode.VOICE_RECOGNITION_FAILED -> AppError.VOICE_RECOGNITION_FAILED + ServerErrorCode.UNKNOWN -> AppError.UNKNOWN } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt new file mode 100644 index 00000000..73795356 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt @@ -0,0 +1,69 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.data.error.mapDomainFailure +import com.team.prezel.core.domain.repository.practice.PracticeRepository +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.model.practice.PracticeRecordingOverallEvaluation +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.core.model.practice.PracticeScript +import com.team.prezel.core.network.datasource.PracticeRemoteDataSource +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import javax.inject.Inject +import kotlin.math.roundToInt + +internal class PracticeRepositoryImpl @Inject constructor( + private val practiceRemoteDataSource: PracticeRemoteDataSource, +) : PracticeRepository { + override suspend fun fetchPracticeScript(): Result = + runCatching { + practiceRemoteDataSource.getPracticeSentence() + }.mapCatching { response -> + response.toDomain() + }.mapDomainFailure() + + override suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): Result = + runCatching { + practiceRemoteDataSource.analyzePracticeRecording( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ) + }.mapCatching { response -> + response.toDomain() + }.mapDomainFailure() + + private fun PracticeSentenceResponse.toDomain(): PracticeScript = + PracticeScript( + id = PRACTICE_SCRIPT_ID, + content = sentence, + ) + + private fun AnalyzePracticeRecordingResponse.toDomain(): PracticeRecordingAnalysisResult = + PracticeRecordingAnalysisResult( + pronunciationScore = accuracyScore.roundToInt(), + speed = speedEvaluation.toPracticeRecordingSpeed(), + overallEvaluation = overallEvaluation.toPracticeRecordingOverallEvaluation(), + ) + + private fun String.toPracticeRecordingSpeed(): PracticeRecordingSpeed = + when { + contains("느려요") -> PracticeRecordingSpeed.SLOW + contains("빨라요") -> PracticeRecordingSpeed.FAST + else -> PracticeRecordingSpeed.ADEQUATE + } + + private fun String.toPracticeRecordingOverallEvaluation(): PracticeRecordingOverallEvaluation = + when (this) { + "Perfect" -> PracticeRecordingOverallEvaluation.PERFECT + "Good" -> PracticeRecordingOverallEvaluation.GOOD + "Try" -> PracticeRecordingOverallEvaluation.TRY + else -> PracticeRecordingOverallEvaluation.TRY + } + + private companion object { + const val PRACTICE_SCRIPT_ID = 0L + } +} diff --git a/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt b/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt new file mode 100644 index 00000000..785093e6 --- /dev/null +++ b/Prezel/core/data/src/test/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImplTest.kt @@ -0,0 +1,76 @@ +package com.team.prezel.core.data.repository.practice + +import com.team.prezel.core.data.repository.PracticeRepositoryImpl +import com.team.prezel.core.model.practice.PracticeRecordingOverallEvaluation +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.core.network.datasource.PracticeRemoteDataSource +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +class PracticeRepositoryImplTest { + @Test + fun `연습 녹음 대본을 조회한다`() = + runBlocking { + val repository = PracticeRepositoryImpl( + practiceRemoteDataSource = FakePracticeRemoteDataSource( + sentence = "간장 공장 공장장은 강 공장장이다.", + ), + ) + + val result = repository.fetchPracticeScript().getOrThrow() + + assertEquals(0L, result.id) + assertEquals("간장 공장 공장장은 강 공장장이다.", result.content) + } + + @Test + fun `연습 녹음 분석을 요청하고 분석 결과로 변환한다`() = + runBlocking { + val remoteDataSource = FakePracticeRemoteDataSource( + sentence = "간장 공장 공장장은 강 공장장이다.", + ) + val repository = PracticeRepositoryImpl( + practiceRemoteDataSource = remoteDataSource, + ) + + val result = repository + .analyzePracticeRecording( + recordingFilePath = "/tmp/practice.wav", + referenceText = "간장 공장 공장장은 강 공장장이다.", + ).getOrThrow() + + assertEquals("/tmp/practice.wav", remoteDataSource.analyzeRecordingFilePath) + assertEquals("간장 공장 공장장은 강 공장장이다.", remoteDataSource.analyzeReferenceText) + assertEquals(86, result.pronunciationScore) + assertEquals(PracticeRecordingSpeed.ADEQUATE, result.speed) + assertEquals(PracticeRecordingOverallEvaluation.GOOD, result.overallEvaluation) + } + + private class FakePracticeRemoteDataSource( + private val sentence: String, + ) : PracticeRemoteDataSource { + var analyzeRecordingFilePath: String? = null + private set + var analyzeReferenceText: String? = null + private set + + override suspend fun getPracticeSentence(): PracticeSentenceResponse = PracticeSentenceResponse(sentence = sentence) + + override suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): AnalyzePracticeRecordingResponse { + analyzeRecordingFilePath = recordingFilePath + analyzeReferenceText = referenceText + + return AnalyzePracticeRecordingResponse( + accuracyScore = 85.5, + speedEvaluation = "적당해요", + overallEvaluation = "Good", + ) + } + } +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/practice/PracticeRepository.kt new file mode 100644 index 00000000..340ccf6a --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/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.PracticeScript + +interface PracticeRepository { + suspend fun fetchPracticeScript(): Result + + suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): Result +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt new file mode 100644 index 00000000..021eda97 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.kt @@ -0,0 +1,18 @@ +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 + +class AnalyzePracticeRecordingUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke( + recordingFilePath: String, + referenceText: String, + ): Result = + practiceRepository.analyzePracticeRecording( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ) +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt new file mode 100644 index 00000000..f8e16653 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.kt @@ -0,0 +1,11 @@ +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 + +class FetchPracticeScriptUseCase @Inject constructor( + private val practiceRepository: PracticeRepository, +) { + suspend operator fun invoke(): Result = practiceRepository.fetchPracticeScript() +} 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..f755d27b --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.model.practice + +data class PracticeRecordingAnalysisResult( + val pronunciationScore: Int, + val speed: PracticeRecordingSpeed, + val overallEvaluation: PracticeRecordingOverallEvaluation, +) + +enum class PracticeRecordingSpeed { + SLOW, + ADEQUATE, + FAST, +} + +enum class PracticeRecordingOverallEvaluation { + PERFECT, + GOOD, + TRY, +} 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/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSource.kt new file mode 100644 index 00000000..1619412d --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSource.kt @@ -0,0 +1,13 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse + +interface PracticeRemoteDataSource { + suspend fun getPracticeSentence(): PracticeSentenceResponse + + suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): AnalyzePracticeRecordingResponse +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSourceImpl.kt new file mode 100644 index 00000000..93075ad3 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PracticeRemoteDataSourceImpl.kt @@ -0,0 +1,43 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.service.PracticeService +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import java.io.File +import javax.inject.Inject + +internal class PracticeRemoteDataSourceImpl @Inject constructor( + private val practiceService: PracticeService, +) : PracticeRemoteDataSource { + override suspend fun getPracticeSentence(): PracticeSentenceResponse = practiceService.getPracticeSentence().requireData() + + override suspend fun analyzePracticeRecording( + recordingFilePath: String, + referenceText: String, + ): AnalyzePracticeRecordingResponse { + val audioFile = File(recordingFilePath) + val multipart = MultiPartFormDataContent( + formData { + append( + key = "audio", + value = audioFile.readBytes(), + headers = Headers.build { + append(HttpHeaders.ContentType, "audio/${audioFile.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") + }, + ) + }, + ) + + return practiceService + .analyzePracticeRecording( + referenceText = referenceText, + audio = multipart, + ).requireData() + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt index 606ef58e..94809a86 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt @@ -2,6 +2,8 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.datasource.AuthRemoteDataSource import com.team.prezel.core.network.datasource.AuthRemoteDataSourceImpl +import com.team.prezel.core.network.datasource.PracticeRemoteDataSource +import com.team.prezel.core.network.datasource.PracticeRemoteDataSourceImpl import com.team.prezel.core.network.datasource.TermsRemoteDataSource import com.team.prezel.core.network.datasource.TermsRemoteDataSourceImpl import com.team.prezel.core.network.datasource.UserRemoteDataSource @@ -23,6 +25,10 @@ internal abstract class DataSourceModule { @Singleton abstract fun bindUserRemoteDataSource(impl: UserRemoteDataSourceImpl): UserRemoteDataSource + @Binds + @Singleton + abstract fun bindPracticeRemoteDataSource(impl: PracticeRemoteDataSourceImpl): PracticeRemoteDataSource + @Binds @Singleton abstract fun bindTermsRemoteDataSource(impl: TermsRemoteDataSourceImpl): TermsRemoteDataSource diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index 9796d08a..f8179227 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -2,9 +2,11 @@ package com.team.prezel.core.network.di import com.team.prezel.core.network.client.HttpClientFactory import com.team.prezel.core.network.service.AuthService +import com.team.prezel.core.network.service.PracticeService import com.team.prezel.core.network.service.TermsService import com.team.prezel.core.network.service.UserService import com.team.prezel.core.network.service.createAuthService +import com.team.prezel.core.network.service.createPracticeService import com.team.prezel.core.network.service.createTermsService import com.team.prezel.core.network.service.createUserService import dagger.Module @@ -38,6 +40,10 @@ object NetworkModule { @Singleton internal fun provideUserService(ktorfit: Ktorfit): UserService = ktorfit.createUserService() + @Provides + @Singleton + internal fun providePracticeService(ktorfit: Ktorfit): PracticeService = ktorfit.createPracticeService() + @Provides @Singleton internal fun provideTermsService(ktorfit: Ktorfit): TermsService = ktorfit.createTermsService() diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt index 2823c33e..7b6cd46d 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt @@ -57,6 +57,9 @@ enum class ServerErrorCode( REQUIRED_TERMS_DISAGREED("TR002"), FILE_IS_EMPTY("F001"), FILE_UPLOAD_FAILED("F002"), + SENTENCE_NOT_FOUND("S001"), + VOICE_RECOGNITION_FAILED("V001"), + VOICE_ANALYSIS_FAILED("V002"), UNKNOWN("UNKNOWN"), ; diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/AnalyzePracticeRecordingResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/AnalyzePracticeRecordingResponse.kt new file mode 100644 index 00000000..31110982 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/AnalyzePracticeRecordingResponse.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.network.model.practice + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AnalyzePracticeRecordingResponse( + @SerialName("accuracyScore") + val accuracyScore: Double, + @SerialName("speedEvaluation") + val speedEvaluation: String, + @SerialName("overallEvaluation") + val overallEvaluation: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PracticeSentenceResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PracticeSentenceResponse.kt new file mode 100644 index 00000000..80439075 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/practice/PracticeSentenceResponse.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.network.model.practice + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PracticeSentenceResponse( + @SerialName("sentence") + val sentence: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PracticeService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PracticeService.kt new file mode 100644 index 00000000..e0c1fa8a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PracticeService.kt @@ -0,0 +1,21 @@ +package com.team.prezel.core.network.service + +import com.team.prezel.core.network.model.BaseResponse +import com.team.prezel.core.network.model.practice.AnalyzePracticeRecordingResponse +import com.team.prezel.core.network.model.practice.PracticeSentenceResponse +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Query +import io.ktor.client.request.forms.MultiPartFormDataContent + +interface PracticeService { + @GET("recording/practice/sentence") + suspend fun getPracticeSentence(): BaseResponse + + @POST("recording/practice/analyze") + suspend fun analyzePracticeRecording( + @Query("referenceText") referenceText: String, + @Body audio: MultiPartFormDataContent, + ): BaseResponse +} 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..42062125 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { implementation(projects.coreModel) implementation(projects.featureHomeApi) + implementation(projects.featurePracticeApi) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.datetime) 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..f7231ebe 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.practice.api.PracticeNavKey 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(PracticeNavKey.Recording) }, 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..b84d5b82 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 @@ -3,7 +3,7 @@ package com.team.prezel.feature.home.impl.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey 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 dagger.Module import dagger.Provides import dagger.hilt.InstallIn 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..7e897498 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,8 @@ 학술·교육 업무·보고 + 연습하기 + 데이터를 불러오지 못했습니다. diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt index 9fa7fc43..b54077b7 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/LoginScreen.kt @@ -84,7 +84,7 @@ internal fun SharedTransitionScope.LoginScreen( LoginUiMessage.LOGIN_CANCELLED -> R.string.feature_login_impl_kakao_cancelled LoginUiMessage.LOGIN_FAILED_UNKNOWN -> R.string.feature_login_impl_login_failed } - snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId), useRaisedPosition = false) } } } diff --git a/Prezel/feature/practice/api/.gitignore b/Prezel/feature/practice/api/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/Prezel/feature/practice/api/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Prezel/feature/practice/api/build.gradle.kts b/Prezel/feature/practice/api/build.gradle.kts new file mode 100644 index 00000000..5b34fdbb --- /dev/null +++ b/Prezel/feature/practice/api/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.android.feature.api) +} + +android { + namespace = "com.team.prezel.feature.practice.api" +} diff --git a/Prezel/feature/practice/api/consumer-rules.pro b/Prezel/feature/practice/api/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/feature/practice/api/proguard-rules.pro b/Prezel/feature/practice/api/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/practice/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt b/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt new file mode 100644 index 00000000..a90315b5 --- /dev/null +++ b/Prezel/feature/practice/api/src/main/java/com/team/prezel/feature/practice/api/PracticeNavKey.kt @@ -0,0 +1,16 @@ +package com.team.prezel.feature.practice.api + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +sealed interface PracticeNavKey : NavKey { + @Serializable + data object Recording : PracticeNavKey + + @Serializable + data class Analysis( + val recordingFilePath: String, + val referenceText: String, + ) : PracticeNavKey +} diff --git a/Prezel/feature/practice/impl/.gitignore b/Prezel/feature/practice/impl/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/Prezel/feature/practice/impl/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Prezel/feature/practice/impl/build.gradle.kts b/Prezel/feature/practice/impl/build.gradle.kts new file mode 100644 index 00000000..519665ed --- /dev/null +++ b/Prezel/feature/practice/impl/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.prezel.android.feature.impl) +} + +android { + namespace = "com.team.prezel.feature.practice.impl" +} + +dependencies { + implementation(projects.coreAudio) + implementation(projects.coreDomain) + implementation(projects.coreModel) + implementation(projects.featureHomeApi) + implementation(projects.featurePracticeApi) +} diff --git a/Prezel/feature/practice/impl/consumer-rules.pro b/Prezel/feature/practice/impl/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/feature/practice/impl/proguard-rules.pro b/Prezel/feature/practice/impl/proguard-rules.pro new file mode 100644 index 00000000..f1b42451 --- /dev/null +++ b/Prezel/feature/practice/impl/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt new file mode 100644 index 00000000..58f598a6 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingScreen.kt @@ -0,0 +1,214 @@ +package com.team.prezel.feature.practice.impl + +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.runtime.rememberCoroutineScope +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.practice.impl.component.PracticeRecordingContent +import com.team.prezel.feature.practice.impl.component.PracticeRecordingTopAppBar +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage +import kotlinx.coroutines.launch + +@Composable +internal fun PracticeRecordingScreen( + onBack: () -> Unit, + navigateToAnalysis: (recordingFilePath: String, referenceText: String) -> Unit, + modifier: Modifier = Modifier, + viewModel: PracticeRecordingViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + val coroutineScope = rememberCoroutineScope() + val showMessage: (PracticeRecordingUiMessage) -> Unit = { message -> + coroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar(message = resources.getString(message.resId)) + } + } + val onClickRecordingControl = rememberRecordAudioPermissionControlClickHandler( + recordingState = uiState.recordingState, + onClickRecordingControl = { viewModel.onIntent(PracticeRecordingUiIntent.ClickRecordingControl) }, + onPermissionDenied = { showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED) }, + onPermissionPermanentlyDenied = { + showMessage(PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED) + }, + ) + + LaunchedEffect(Unit) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is PracticeRecordingUiEffect.ShowMessage -> { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.resId)) + } + } + } + } + + PracticeRecordingScreen( + uiState = uiState, + onClickRecordingControl = onClickRecordingControl, + onClickAnalyze = { + val recordingFilePath = uiState.recordingFilePath ?: return@PracticeRecordingScreen + navigateToAnalysis(recordingFilePath, uiState.practiceScript) + }, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun PracticeRecordingScreen( + uiState: PracticeRecordingUiState, + onClickRecordingControl: () -> Unit, + onClickAnalyze: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + BackHandler( + onBack = onBack, + ) + + PracticeRecordingReadyScreen( + uiState = uiState, + onClickRecordingControl = onClickRecordingControl, + onClickAnalyze = onClickAnalyze, + onBack = onBack, + modifier = modifier, + ) +} + +@Composable +private fun PracticeRecordingReadyScreen( + uiState: PracticeRecordingUiState, + onClickRecordingControl: () -> Unit, + onClickAnalyze: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + PracticeRecordingTopAppBar(onBack = onBack) + + PracticeRecordingContent( + practiceScript = uiState.practiceScript, + currentSeconds = uiState.currentSeconds, + totalSeconds = uiState.totalSeconds, + recordingState = uiState.recordingState, + onClickRecordingControl = onClickRecordingControl, + modifier = Modifier.weight(1f), + ) + + PrezelButtonArea( + mainButton = { buttonModifier -> + PrezelButton( + text = stringResource(R.string.feature_practice_impl_practice_recording_analyze), + modifier = buttonModifier, + enabled = uiState.analyzeEnabled, + onClick = onClickAnalyze, + ) + }, + ) + } +} + +private val PracticeRecordingUiMessage.resId: Int + get() = when (this) { + PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED -> R.string.feature_practice_impl_practice_recording_fetch_script_failed + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_DENIED -> R.string.feature_practice_impl_practice_recording_permission_denied + PracticeRecordingUiMessage.RECORD_AUDIO_PERMISSION_PERMANENTLY_DENIED -> + R.string.feature_practice_impl_practice_recording_permission_permanently_denied + + PracticeRecordingUiMessage.RECORDING_START_FAILED -> R.string.feature_practice_impl_practice_recording_failed + PracticeRecordingUiMessage.RECORDING_STOP_FAILED -> R.string.feature_practice_impl_practice_recording_stop_failed + PracticeRecordingUiMessage.PLAYBACK_START_FAILED -> R.string.feature_practice_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네가 그린 기린 그림은 잘못 그린 기린 그림이다.", + ), + onClickRecordingControl = {}, + onClickAnalyze = {}, + onBack = {}, + ) +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt new file mode 100644 index 00000000..dae02013 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/PracticeRecordingViewModel.kt @@ -0,0 +1,98 @@ +package com.team.prezel.feature.practice.impl + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.audio.AudioSessionEffect +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.audio.RecordingAudioController +import com.team.prezel.core.domain.usecase.practice.FetchPracticeScriptUseCase +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiEffect +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiIntent +import com.team.prezel.feature.practice.impl.contract.PracticeRecordingUiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class PracticeRecordingViewModel @Inject constructor( + private val audioController: RecordingAudioController, + private val fetchPracticeScriptUseCase: FetchPracticeScriptUseCase, +) : BaseViewModel(PracticeRecordingUiState()) { + init { + collectAudioSessionState() + collectAudioSessionEffect() + fetchPracticeScript() + } + + override fun onIntent(intent: PracticeRecordingUiIntent) { + when (intent) { + PracticeRecordingUiIntent.ClickRecordingControl -> handleRecordingControlClick() + } + } + + private fun handleRecordingControlClick() { + when (currentState.recordingState) { + AudioSessionState.Idle -> { + if (currentState.practiceScript.isBlank()) { + showMessage(PracticeRecordingUiMessage.FETCH_PRACTICE_SCRIPT_FAILED) + return + } + audioController.startRecording() + } + + is AudioSessionState.Recording -> audioController.stopRecording() + is AudioSessionState.ReadyToPlay -> audioController.startPlayback() + is AudioSessionState.Playing -> audioController.stopPlayback() + } + } + + 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 showMessage(message: PracticeRecordingUiMessage) { + viewModelScope.launch { + sendEffect(PracticeRecordingUiEffect.ShowMessage(message)) + } + } + + 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 + } + + override fun onCleared() { + audioController.release() + super.onCleared() + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt new file mode 100644 index 00000000..bc315f4b --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/RecordAudioPermission.kt @@ -0,0 +1,38 @@ +package com.team.prezel.feature.practice.impl + +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, + onClickRecordingControl: () -> Unit, + onPermissionDenied: () -> Unit, + onPermissionPermanentlyDenied: () -> Unit, +): () -> Unit { + val permissionRequest = rememberPermissionRequest( + permission = Manifest.permission.RECORD_AUDIO, + onPermissionGranted = onClickRecordingControl, + onPermissionDenied = onPermissionDenied, + onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, + ) + + return { + when (recordingState) { + AudioSessionState.Idle -> { + when { + permissionRequest.isGranted -> onClickRecordingControl() + permissionRequest.isPermanentlyDenied -> permissionRequest.onPermanentlyDenied() + else -> permissionRequest.launch() + } + } + + is AudioSessionState.Recording, + is AudioSessionState.ReadyToPlay, + is AudioSessionState.Playing, + -> onClickRecordingControl() + } + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisScreen.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisScreen.kt new file mode 100644 index 00000000..2c949e39 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisScreen.kt @@ -0,0 +1,66 @@ +package com.team.prezel.feature.practice.impl.analysis + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.feature.practice.impl.analysis.component.PracticeRecordingAnalysisFailurePage +import com.team.prezel.feature.practice.impl.analysis.component.PracticeRecordingAnalysisLoadingPage +import com.team.prezel.feature.practice.impl.analysis.component.PracticeRecordingResultPage +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiIntent +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiState + +@Composable +internal fun PracticeRecordingAnalysisScreen( + recordingFilePath: String, + referenceText: String, + onRetry: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PracticeRecordingAnalysisViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(recordingFilePath, referenceText) { + viewModel.onIntent( + PracticeRecordingAnalysisUiIntent.Analyze( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ), + ) + } + + PracticeRecordingAnalysisScreen( + uiState = uiState, + onRetry = onRetry, + onComplete = onComplete, + modifier = modifier, + ) +} + +@Composable +private fun PracticeRecordingAnalysisScreen( + uiState: PracticeRecordingAnalysisUiState, + onRetry: () -> Unit, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + when (uiState) { + is PracticeRecordingAnalysisUiState.Loading -> PracticeRecordingAnalysisLoadingPage(modifier = modifier) + is PracticeRecordingAnalysisUiState.Success -> PracticeRecordingResultPage( + pronunciationScore = uiState.result.pronunciationScore, + speed = uiState.result.speed, + overallEvaluation = uiState.result.overallEvaluation, + onComplete = onComplete, + modifier = modifier, + ) + + is PracticeRecordingAnalysisUiState.Error -> PracticeRecordingAnalysisFailurePage( + errorType = uiState.type, + onRetry = onRetry, + modifier = modifier, + ) + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisViewModel.kt new file mode 100644 index 00000000..b961af8c --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeRecordingAnalysisViewModel.kt @@ -0,0 +1,77 @@ +package com.team.prezel.feature.practice.impl.analysis + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.common.error.AppError +import com.team.prezel.core.common.error.AppException +import com.team.prezel.core.domain.usecase.practice.AnalyzePracticeRecordingUseCase +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiEffect +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiIntent +import com.team.prezel.feature.practice.impl.analysis.contract.PracticeRecordingAnalysisUiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class PracticeRecordingAnalysisViewModel @Inject constructor( + private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, +) : BaseViewModel( + PracticeRecordingAnalysisUiState.Loading, + ) { + private var analysisRequest: AnalysisRequest? = null + + override fun onIntent(intent: PracticeRecordingAnalysisUiIntent) { + when (intent) { + is PracticeRecordingAnalysisUiIntent.Analyze -> analyzeRecording( + recordingFilePath = intent.recordingFilePath, + referenceText = intent.referenceText, + ) + } + } + + private fun analyzeRecording( + recordingFilePath: String, + referenceText: String, + ) { + val request = AnalysisRequest( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ) + if (analysisRequest == request) return + + analysisRequest = request + viewModelScope.launch { + updateState { PracticeRecordingAnalysisUiState.Loading } + + analyzePracticeRecordingUseCase( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ).onSuccess { result -> + updateState { + PracticeRecordingAnalysisUiState.Success(result = result) + } + }.onFailure { throwable -> + updateState { + PracticeRecordingAnalysisUiState.Error( + type = throwable.toPracticeRecordingAnalysisErrorType(), + ) + } + } + } + } + + private data class AnalysisRequest( + val recordingFilePath: String, + val referenceText: String, + ) + + private fun Throwable.toPracticeRecordingAnalysisErrorType(): PracticeRecordingAnalysisErrorType { + val error = (this as? AppException)?.error + + return when (error) { + AppError.VOICE_RECOGNITION_FAILED -> PracticeRecordingAnalysisErrorType.VOICE_RECOGNITION_FAILED + else -> PracticeRecordingAnalysisErrorType.ANALYSIS_FAILED + } + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisFailurePage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisFailurePage.kt new file mode 100644 index 00000000..7ae82e4c --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisFailurePage.kt @@ -0,0 +1,81 @@ +package com.team.prezel.feature.practice.impl.analysis.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.practice.impl.R +import com.team.prezel.feature.practice.impl.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_practice_impl_practice_recording_analysis_error_title), + description = stringResource(R.string.feature_practice_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_practice_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/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisLoadingPage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisLoadingPage.kt new file mode 100644 index 00000000..eca3cd7e --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingAnalysisLoadingPage.kt @@ -0,0 +1,36 @@ +package com.team.prezel.feature.practice.impl.analysis.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.practice.impl.R +import com.team.prezel.core.ui.R as CoreUiR + +@Composable +internal fun PracticeRecordingAnalysisLoadingPage(modifier: Modifier = Modifier) { + StatusView( + title = stringResource(R.string.feature_practice_impl_practice_recording_analysis_loading_title), + description = stringResource(R.string.feature_practice_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/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingResultPage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingResultPage.kt new file mode 100644 index 00000000..a9e761a9 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/component/PracticeRecordingResultPage.kt @@ -0,0 +1,233 @@ +package com.team.prezel.feature.practice.impl.analysis.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.chip.PrezelChip +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.practice.PracticeRecordingOverallEvaluation +import com.team.prezel.core.model.practice.PracticeRecordingSpeed +import com.team.prezel.feature.practice.impl.R + +@Composable +internal fun PracticeRecordingResultPage( + pronunciationScore: Int, + speed: PracticeRecordingSpeed, + overallEvaluation: PracticeRecordingOverallEvaluation, + onComplete: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), + ) { + PracticeRecordingResultContent( + cardResId = overallEvaluation.cardResId, + cardContentDescription = stringResource(overallEvaluation.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_practice_impl_practice_recording_analysis_complete) + + PrezelButtonArea( + modifier = modifier, + mainButton = { buttonModifier -> + PrezelButton( + text = completeLabel, + modifier = buttonModifier, + enabled = true, + onClick = onComplete, + ) + }, + ) +} + +@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_practice_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_practice_impl_practice_recording_analysis_speed)) + PrezelChip(text = stringResource(speed.labelResId)) + } + } +} + +private val PracticeRecordingSpeed.labelResId: Int + @StringRes + get() = when (this) { + PracticeRecordingSpeed.SLOW -> R.string.feature_practice_impl_practice_recording_analysis_speed_slow + PracticeRecordingSpeed.ADEQUATE -> R.string.feature_practice_impl_practice_recording_analysis_speed_adequate + PracticeRecordingSpeed.FAST -> R.string.feature_practice_impl_practice_recording_analysis_speed_fast + } + +private val PracticeRecordingOverallEvaluation.contentDescriptionResId: Int + @StringRes + get() = when (this) { + PracticeRecordingOverallEvaluation.PERFECT -> R.string.feature_practice_impl_practice_recording_analysis_card_perfect + PracticeRecordingOverallEvaluation.GOOD -> R.string.feature_practice_impl_practice_recording_analysis_card_good + PracticeRecordingOverallEvaluation.TRY -> R.string.feature_practice_impl_practice_recording_analysis_card_try + } + +private val PracticeRecordingOverallEvaluation.cardResId: Int + @DrawableRes + get() = when (this) { + PracticeRecordingOverallEvaluation.PERFECT -> R.drawable.feature_practice_impl_card_perfect + PracticeRecordingOverallEvaluation.GOOD -> R.drawable.feature_practice_impl_card_good + PracticeRecordingOverallEvaluation.TRY -> R.drawable.feature_practice_impl_card_try + } + +@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, + overallEvaluation = PracticeRecordingOverallEvaluation.PERFECT, + onComplete = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingResultGoodPagePreview() { + PrezelTheme { + PracticeRecordingResultPage( + pronunciationScore = 90, + speed = PracticeRecordingSpeed.ADEQUATE, + overallEvaluation = PracticeRecordingOverallEvaluation.GOOD, + onComplete = {}, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingResultTryPagePreview() { + PrezelTheme { + PracticeRecordingResultPage( + pronunciationScore = 58, + speed = PracticeRecordingSpeed.FAST, + overallEvaluation = PracticeRecordingOverallEvaluation.TRY, + onComplete = {}, + ) + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiEffect.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiEffect.kt new file mode 100644 index 00000000..b99fb5c3 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiEffect.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.practice.impl.analysis.contract + +import com.team.prezel.core.ui.base.UiEffect + +internal sealed interface PracticeRecordingAnalysisUiEffect : UiEffect diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiIntent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiIntent.kt new file mode 100644 index 00000000..f00b443d --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiIntent.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.practice.impl.analysis.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface PracticeRecordingAnalysisUiIntent : UiIntent { + data class Analyze( + val recordingFilePath: String, + val referenceText: String, + ) : PracticeRecordingAnalysisUiIntent +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiState.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiState.kt new file mode 100644 index 00000000..40639207 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/contract/PracticeRecordingAnalysisUiState.kt @@ -0,0 +1,22 @@ +package com.team.prezel.feature.practice.impl.analysis.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.practice.PracticeRecordingAnalysisResult +import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.practice.impl.model.PracticeRecordingAnalysisErrorType + +@Immutable +internal sealed interface PracticeRecordingAnalysisUiState : UiState { + @Immutable + data object Loading : PracticeRecordingAnalysisUiState + + @Immutable + data class Success( + val result: PracticeRecordingAnalysisResult, + ) : PracticeRecordingAnalysisUiState + + @Immutable + data class Error( + val type: PracticeRecordingAnalysisErrorType, + ) : PracticeRecordingAnalysisUiState +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt new file mode 100644 index 00000000..41f565a6 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingContent.kt @@ -0,0 +1,115 @@ +package com.team.prezel.feature.practice.impl.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.practice.impl.R + +@Composable +internal fun PracticeRecordingContent( + practiceScript: String, + currentSeconds: Int, + totalSeconds: Int, + recordingState: AudioSessionState, + onClickRecordingControl: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = PrezelTheme.spacing.V20, vertical = PrezelTheme.spacing.V16), + ) { + Text( + text = stringResource(R.string.feature_practice_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, + onClickRecordingControl = onClickRecordingControl, + ) + } +} + +@BasicPreview +@Composable +private fun PracticeRecordingContentReadyToRecordPreview() { + PrezelTheme { + PracticeRecordingContent( + practiceScript = "안녕하세요. 오늘은 제가 준비한 발표 연습을 시작해보겠습니다.", + currentSeconds = 0, + totalSeconds = 0, + recordingState = AudioSessionState.Idle, + onClickRecordingControl = {}, + 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, + ), + onClickRecordingControl = {}, + modifier = Modifier.height(520.dp), + ) + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt new file mode 100644 index 00000000..fd2cc00c --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingControl.kt @@ -0,0 +1,193 @@ +package com.team.prezel.feature.practice.impl.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.graphics.Color +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, + onClickRecordingControl: () -> Unit, + modifier: Modifier = Modifier, +) { + val action = audioSessionState.action() + + 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, + ) { + 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 = onClickRecordingControl, + ) + } + } +} + +@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 iconColor: Color, +) + +@Composable +private fun AudioSessionState.action(): PracticeRecordingControlAction = + when (this) { + AudioSessionState.Idle -> + PracticeRecordingControlAction( + iconResId = PrezelIcons.Recording, + iconColor = PrezelTheme.colors.feedbackBadRegular, + ) + + is AudioSessionState.Recording -> stopAction() + + is AudioSessionState.ReadyToPlay -> + PracticeRecordingControlAction( + iconResId = PrezelIcons.Play, + iconColor = PrezelTheme.colors.iconRegular, + ) + + is AudioSessionState.Playing -> stopAction() + } + +@Composable +private fun stopAction(): PracticeRecordingControlAction = + PracticeRecordingControlAction( + iconResId = PrezelIcons.Stop, + iconColor = 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, + onClickRecordingControl = {}, + ) + + PracticeRecordingControl( + currentSeconds = 8, + totalSeconds = 0, + audioSessionState = AudioSessionState.Recording(elapsedSeconds = 8), + onClickRecordingControl = {}, + ) + + PracticeRecordingControl( + currentSeconds = 16, + totalSeconds = 45, + audioSessionState = AudioSessionState.ReadyToPlay( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 16, + durationSeconds = 45, + ), + onClickRecordingControl = {}, + ) + + PracticeRecordingControl( + currentSeconds = 24, + totalSeconds = 45, + audioSessionState = AudioSessionState.Playing( + source = AudioSource.RecordedFile(filePath = "preview.m4a"), + positionSeconds = 24, + durationSeconds = 45, + ), + onClickRecordingControl = {}, + ) + } + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingTopAppBar.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingTopAppBar.kt new file mode 100644 index 00000000..988e7b7d --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/component/PracticeRecordingTopAppBar.kt @@ -0,0 +1,38 @@ +package com.team.prezel.feature.practice.impl.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.practice.impl.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PracticeRecordingTopAppBar(onBack: () -> Unit) { + PrezelTopAppBar( + title = { Text(text = stringResource(R.string.feature_practice_impl_practice_recording_title)) }, + leadingIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_practice_impl_practice_recording_back), + ) + } + }, + ) +} + +@BasicPreview +@Composable +private fun PracticeRecordingTopAppBarPreview() { + PrezelTheme { + PracticeRecordingTopAppBar(onBack = {}) + } +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiEffect.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiEffect.kt new file mode 100644 index 00000000..20831a52 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiEffect.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.practice.impl.contract + +import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.practice.impl.model.PracticeRecordingUiMessage + +internal sealed interface PracticeRecordingUiEffect : UiEffect { + data class ShowMessage( + val message: PracticeRecordingUiMessage, + ) : PracticeRecordingUiEffect +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt new file mode 100644 index 00000000..0fefbd03 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiIntent.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.practice.impl.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface PracticeRecordingUiIntent : UiIntent { + data object ClickRecordingControl : PracticeRecordingUiIntent +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt new file mode 100644 index 00000000..fbb91641 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/contract/PracticeRecordingUiState.kt @@ -0,0 +1,39 @@ +package com.team.prezel.feature.practice.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.audio.AudioSessionState +import com.team.prezel.core.ui.base.UiState + +@Immutable +internal data class PracticeRecordingUiState( + val practiceScript: String = "", + val recordingState: AudioSessionState = AudioSessionState.Idle, +) : 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 +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisErrorType.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisErrorType.kt new file mode 100644 index 00000000..ec2ee86b --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingAnalysisErrorType.kt @@ -0,0 +1,6 @@ +package com.team.prezel.feature.practice.impl.model + +internal enum class PracticeRecordingAnalysisErrorType { + VOICE_RECOGNITION_FAILED, + ANALYSIS_FAILED, +} diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingUiMessage.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingUiMessage.kt new file mode 100644 index 00000000..8a298ef6 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/model/PracticeRecordingUiMessage.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.practice.impl.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/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt new file mode 100644 index 00000000..d005e6f6 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeEntryBuilder.kt @@ -0,0 +1,54 @@ +package com.team.prezel.feature.practice.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.practice.api.PracticeNavKey +import com.team.prezel.feature.practice.impl.PracticeRecordingScreen +import com.team.prezel.feature.practice.impl.analysis.PracticeRecordingAnalysisScreen +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityRetainedComponent +import dagger.multibindings.IntoSet + +internal fun EntryProviderScope.featurePracticeEntryBuilder() { + entry { + val navigator = LocalNavigator.current + + PracticeRecordingScreen( + onBack = navigator::goBack, + navigateToAnalysis = { recordingFilePath, referenceText -> + navigator.navigate( + PracticeNavKey.Analysis( + recordingFilePath = recordingFilePath, + referenceText = referenceText, + ), + ) + }, + ) + } + + entry { key -> + val navigator = LocalNavigator.current + + PracticeRecordingAnalysisScreen( + recordingFilePath = key.recordingFilePath, + referenceText = key.referenceText, + onRetry = { navigator.navigate(PracticeNavKey.Recording) }, + onComplete = { navigator.replaceRoot(HomeNavKey) }, + ) + } +} + +@Module +@InstallIn(ActivityRetainedComponent::class) +object FeaturePracticeModule { + @IntoSet + @Provides + fun provideFeaturePracticeEntryBuilder(): EntryProviderScope.() -> Unit = + { + featurePracticeEntryBuilder() + } +} diff --git a/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_good.xml b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_good.xml new file mode 100644 index 00000000..a82572ee --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_good.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_perfect.xml b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_perfect.xml new file mode 100644 index 00000000..d1c29065 --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_perfect.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_try.xml b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_try.xml new file mode 100644 index 00000000..161c39cc --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/res/drawable/feature_practice_impl_card_try.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/practice/impl/src/main/res/values/strings.xml b/Prezel/feature/practice/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..272f590f --- /dev/null +++ b/Prezel/feature/practice/impl/src/main/res/values/strings.xml @@ -0,0 +1,27 @@ + + + 연습 녹음 + 뒤로가기 + 아래 문장을 소리내어 읽어주세요. + 분석하기 + 대본을 불러오지 못했습니다. + 마이크 권한이 필요합니다. + 설정에서 마이크 권한을 허용해 주세요. + 녹음을 시작하지 못했습니다. + 녹음을 저장하지 못했습니다. + 녹음을 재생하지 못했습니다. + 분석중 + 잠시만 기다려주세요 + 분석에 실패했어요 + 음성이 작거나 주변 소음이 많았을 수 있어요.\n조용한 환경에서 다시 시도해 주세요. + 다시 시도하기 + 완료 + 발화 + 속도 + 느려요 + 적당해요 + 빨라요 + perfect + good + try + diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt index 9577e50d..fd8db9fa 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt @@ -90,6 +90,7 @@ internal class TermsViewModel @Inject constructor( AppError.SERVER_ERROR -> TermsUiMessage.AGREE_TERMS_FAILED_SERVER AppError.NOT_FOUND, AppError.DUPLICATE, + AppError.VOICE_RECOGNITION_FAILED, AppError.UNKNOWN, null, -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN 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..41f5109a 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", @@ -51,6 +52,8 @@ includeAuto( ":core:ui", ":feature:home:api", ":feature:home:impl", + ":feature:practice:api", + ":feature:practice:impl", ":feature:history:api", ":feature:history:impl", ":feature:my:api",