From c22a535078a69221c4c42c99588fbf9b5b833d3e Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 14:47:21 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=B0=8F=20=EC=97=B0=EC=8A=B5=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 메인 데이터 및 연습 기록 관련 도메인 모델 및 UseCase 추가** * 메인 화면의 대시보드 데이터를 위한 `MainData`와 프레젠테이션 연습 일정을 위한 `PracticeRecords` 모델을 추가했습니다. * `FetchMainDataUseCase` 및 `FetchPracticeRecordsUseCase`를 구현하여 관련 비즈니스 로직을 제공합니다. * **feat: 네트워크 레이어 API 연동 및 DTO 정의** * `PresentationService`에 메인 데이터 조회(`GET /main`) 및 연습 기록 조회(`GET /recording/{presentationId}/practice-records`) API를 추가했습니다. * 서버 응답 처리를 위한 `GetMainDataResponse`와 `GetPracticeRecordsResponse` DTO를 정의했습니다. * **refactor: PresentationRepository 및 Data 레이어 구현** * `PresentationRepository` 인터페이스에 새로운 기능들을 추가하고, `PresentationRepositoryImpl`에서 이를 구현했습니다. * `PresentationMapper`를 확장하여 네트워크 응답 DTO를 도메인 모델인 `MainData` 및 `PracticeRecords`로 변환하는 매핑 로직을 추가했습니다. * `PresentationRemoteDataSource`를 통해 실제 네트워크 통신을 수행하도록 연동했습니다. --- .../core/data/mapper/PresentationMapper.kt | 31 ++++++++++++++++ .../repository/PresentationRepositoryImpl.kt | 17 +++++++++ .../presentation/PresentationRepository.kt | 6 ++++ .../presentation/FetchMainDataUseCase.kt | 11 ++++++ .../FetchPracticeRecordsUseCase.kt | 11 ++++++ .../core/model/presentation/MainData.kt | 15 ++++++++ .../model/presentation/PracticeRecords.kt | 9 +++++ .../PresentationRemoteDataSource.kt | 6 ++++ .../PresentationRemoteDataSourceImpl.kt | 7 ++++ .../model/presentation/GetMainDataResponse.kt | 36 +++++++++++++++++++ .../GetPracticeRecordsResponse.kt | 14 ++++++++ .../network/service/PresentationService.kt | 10 ++++++ 12 files changed, 173 insertions(+) create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt create mode 100644 Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPracticeRecordsUseCase.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PracticeRecords.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt create mode 100644 Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetPracticeRecordsResponse.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt index 49720965..d381bd12 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt @@ -4,6 +4,8 @@ import com.team.prezel.core.model.practice.RecordingSpeed import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category import com.team.prezel.core.model.presentation.ExpectedQuestion +import com.team.prezel.core.model.presentation.MainData +import com.team.prezel.core.model.presentation.PracticeRecords import com.team.prezel.core.model.presentation.PresentationAnalysisSummary import com.team.prezel.core.model.presentation.PresentationGrowthPoint import com.team.prezel.core.model.presentation.PresentationInfo @@ -13,6 +15,8 @@ import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.ScriptCorrection import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.model.presentation.WordAnalysisDetail +import com.team.prezel.core.network.model.presentation.GetMainDataResponse +import com.team.prezel.core.network.model.presentation.GetPracticeRecordsResponse import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import com.team.prezel.core.network.model.presentation.PresentationExpectedQuestionResponse import com.team.prezel.core.network.model.presentation.PresentationGrowthResponse @@ -106,3 +110,30 @@ internal fun GetPresentationsResponse.toDomain(): PresentationInfo = audience = Audience.from(value = audience), dDay = dday, ) + +internal fun GetPracticeRecordsResponse.toDomain(): PracticeRecords = + PracticeRecords( + dates = dates.map(LocalDate::parse), + startDate = LocalDate.parse(startDate), + endDate = LocalDate.parse(endDate), + ) + +internal fun GetMainDataResponse.toDomain(): MainData = + MainData( + presentationId = presentationId.toLong(), + title = title, + type = type, + presentationDate = LocalDate.parse(presentationDate), + isPast = isPast, + dDay = dDay, + accuracyScoreChange = accuracyScoreChange, + scriptMatchRateChange = scriptMatchRateChange, + growthGraph = growthGraph.map { item -> item.toDomain() }, + ) + +private fun GetMainDataResponse.GrowthGraph.toDomain(): PresentationGrowthPoint = + PresentationGrowthPoint( + attempt = attempt, + accuracyScore = accuracyScore, + scriptMatchRate = scriptMatchRate, + ) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt index 27c215ed..fc5f0310 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PresentationRepositoryImpl.kt @@ -5,6 +5,8 @@ import com.team.prezel.core.data.mapper.toDomain import com.team.prezel.core.domain.repository.presentation.PresentationRepository import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.MainData +import com.team.prezel.core.model.presentation.PracticeRecords import com.team.prezel.core.model.presentation.PresentationAnalysisSummary import com.team.prezel.core.model.presentation.PresentationInfo import com.team.prezel.core.model.presentation.PresentationScriptDetail @@ -12,6 +14,7 @@ import com.team.prezel.core.model.presentation.PresentationWordDetail import com.team.prezel.core.model.presentation.Purpose import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.network.datasource.PresentationRemoteDataSource +import com.team.prezel.core.network.model.presentation.GetMainDataResponse import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import javax.inject.Inject @@ -104,4 +107,18 @@ internal class PresentationRepositoryImpl @Inject constructor( }.mapCatching { response -> response.toDomain() }.mapDomainFailure() + + override suspend fun getPracticeRecords(presentationId: Long): Result = + runCatching { + presentationRemoteDataSource.getPracticeRecords(presentationId = presentationId) + }.mapCatching { response -> + response.toDomain() + }.mapDomainFailure() + + override suspend fun getMainData(): Result> = + runCatching { + presentationRemoteDataSource.getMainData() + }.mapCatching { response -> + response.map(GetMainDataResponse::toDomain) + }.mapDomainFailure() } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt index 9612cdb2..14e988e0 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/presentation/PresentationRepository.kt @@ -2,6 +2,8 @@ package com.team.prezel.core.domain.repository.presentation import com.team.prezel.core.model.presentation.Audience import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.MainData +import com.team.prezel.core.model.presentation.PracticeRecords import com.team.prezel.core.model.presentation.PresentationAnalysisSummary import com.team.prezel.core.model.presentation.PresentationInfo import com.team.prezel.core.model.presentation.PresentationScriptDetail @@ -40,4 +42,8 @@ interface PresentationRepository { suspend fun getUpcomingPresentationDetail(presentationId: Long): Result suspend fun getPastPresentationDetail(presentationId: Long): Result + + suspend fun getPracticeRecords(presentationId: Long): Result + + suspend fun getMainData(): Result> } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt new file mode 100644 index 00000000..a19f4304 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.presentation + +import com.team.prezel.core.domain.repository.presentation.PresentationRepository +import com.team.prezel.core.model.presentation.MainData +import javax.inject.Inject + +class FetchMainDataUseCase @Inject constructor( + private val repository: PresentationRepository, +) { + suspend operator fun invoke(): Result> = repository.getMainData() +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPracticeRecordsUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPracticeRecordsUseCase.kt new file mode 100644 index 00000000..dfa73848 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPracticeRecordsUseCase.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.presentation + +import com.team.prezel.core.domain.repository.presentation.PresentationRepository +import com.team.prezel.core.model.presentation.PracticeRecords +import javax.inject.Inject + +class FetchPracticeRecordsUseCase @Inject constructor( + private val repository: PresentationRepository, +) { + suspend operator fun invoke(presentationId: Long): Result = repository.getPracticeRecords(presentationId = presentationId) +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt new file mode 100644 index 00000000..aa900450 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.model.presentation + +import kotlinx.datetime.LocalDate + +data class MainData( + val presentationId: Long, + val title: String, + val type: String, + val presentationDate: LocalDate, + val isPast: Boolean, + val dDay: String, + val accuracyScoreChange: Int, + val scriptMatchRateChange: Int, + val growthGraph: List, +) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PracticeRecords.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PracticeRecords.kt new file mode 100644 index 00000000..edcb1f5e --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PracticeRecords.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.model.presentation + +import kotlinx.datetime.LocalDate + +data class PracticeRecords( + val dates: List, + val startDate: LocalDate, + val endDate: LocalDate, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt index b7366c42..28da0ce5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSource.kt @@ -1,5 +1,7 @@ package com.team.prezel.core.network.datasource +import com.team.prezel.core.network.model.presentation.GetMainDataResponse +import com.team.prezel.core.network.model.presentation.GetPracticeRecordsResponse import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import com.team.prezel.core.network.model.presentation.PresentationScriptDetailResponse import com.team.prezel.core.network.model.presentation.PresentationSummaryResponse @@ -36,4 +38,8 @@ interface PresentationRemoteDataSource { suspend fun getUpcomingPresentationDetail(presentationId: Long): PresentationSummaryResponse suspend fun getPastPresentationDetail(presentationId: Long): PresentationSummaryResponse + + suspend fun getPracticeRecords(presentationId: Long): GetPracticeRecordsResponse + + suspend fun getMainData(): List } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt index 8d83fd03..65c5c4fa 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt @@ -1,5 +1,7 @@ package com.team.prezel.core.network.datasource +import com.team.prezel.core.network.model.presentation.GetMainDataResponse +import com.team.prezel.core.network.model.presentation.GetPracticeRecordsResponse import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import com.team.prezel.core.network.model.presentation.PresentationScriptDetailResponse import com.team.prezel.core.network.model.presentation.PresentationSummaryResponse @@ -84,6 +86,11 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( override suspend fun getPastPresentationDetail(presentationId: Long): PresentationSummaryResponse = presentationService.getPastPresentationDetail(presentationId = presentationId).requireData().analysisResult + override suspend fun getPracticeRecords(presentationId: Long): GetPracticeRecordsResponse = + presentationService.getPracticeRecords(presentationId = presentationId).requireData() + + override suspend fun getMainData(): List = presentationService.getMainData().requireData() + private fun String.toAudioMultipart(): MultiPartFormDataContent = MultiPartFormDataContent( formData { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt new file mode 100644 index 00000000..5fbd1865 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt @@ -0,0 +1,36 @@ +package com.team.prezel.core.network.model.presentation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetMainDataResponse( + @SerialName("accuracyScoreChange") + val accuracyScoreChange: Int, + @SerialName("dday") + val dDay: String, + @SerialName("growthGraph") + val growthGraph: List, + @SerialName("isPast") + val isPast: Boolean, + @SerialName("presentationDate") + val presentationDate: String, + @SerialName("presentationId") + val presentationId: Int, + @SerialName("scriptMatchRateChange") + val scriptMatchRateChange: Int, + @SerialName("title") + val title: String, + @SerialName("type") + val type: String, +) { + @Serializable + data class GrowthGraph( + @SerialName("accuracyScore") + val accuracyScore: Double, + @SerialName("attempt") + val attempt: Int, + @SerialName("scriptMatchRate") + val scriptMatchRate: Double, + ) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetPracticeRecordsResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetPracticeRecordsResponse.kt new file mode 100644 index 00000000..d55f3bb3 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetPracticeRecordsResponse.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.network.model.presentation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetPracticeRecordsResponse( + @SerialName("dates") + val dates: List, + @SerialName("endDate") + val endDate: String, + @SerialName("startDate") + val startDate: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt index a2ca7495..ed5fbfa5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/PresentationService.kt @@ -1,6 +1,8 @@ package com.team.prezel.core.network.service import com.team.prezel.core.network.model.BaseResponse +import com.team.prezel.core.network.model.presentation.GetMainDataResponse +import com.team.prezel.core.network.model.presentation.GetPracticeRecordsResponse import com.team.prezel.core.network.model.presentation.GetPresentationDetailResponse import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import com.team.prezel.core.network.model.presentation.PresentationScriptDetailResponse @@ -55,4 +57,12 @@ interface PresentationService { suspend fun getPastPresentationDetail( @Path("presentationId") presentationId: Long, ): BaseResponse + + @GET("recording/{presentationId}/practice-records") + suspend fun getPracticeRecords( + @Path("presentationId") presentationId: Long, + ): BaseResponse + + @GET("main") + suspend fun getMainData(): BaseResponse> } From ca01f19b603b9800477b2fd67f7598f336f14001 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 19:04:19 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=95=B1=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?Dimmer=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20UI=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: `AppDimmerState` 및 `LocalAppDimmerState` 추가** * 앱 전체 화면을 어둡게(Dim) 처리하거나 상태를 관리하기 위한 `AppDimmerState` 클래스를 정의했습니다. * `show`, `hide` 메서드와 함께 Dim 영역 클릭 시 호출될 `onDismissRequest` 콜백 로직을 구현했습니다. * `LocalAppDimmerState` CompositionLocal을 제공하여 하위 컴포넌트에서 전역 Dimmer 상태에 접근할 수 있도록 했습니다. * **feat: `PrezelApp` 내 전역 Dimmer 레이아웃 적용** * `PrezelApp` 최상위 레이아웃에 Dimmer를 위한 `Box` 오버레이를 추가했습니다. * `appDimmerState.isVisible` 상태에 따라 `scrimContainer` 색상의 배경이 나타나도록 설정했습니다. * `noRippleClickable`을 사용하여 Dimmer 영역 클릭 시 설정된 `dismiss` 동작이 수행되도록 구현했습니다. --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 76 ++++++++++++------- .../core/ui/state/LocalAppDimmerState.kt | 38 ++++++++++ 2 files changed, 86 insertions(+), 28 deletions(-) create mode 100644 Prezel/core/ui/src/main/java/com/team/prezel/core/ui/state/LocalAppDimmerState.kt diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 8a419d76..64b1ad86 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -5,6 +5,9 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable @@ -25,7 +28,10 @@ import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope import com.team.prezel.core.navigation.toEntries +import com.team.prezel.core.ui.state.LocalAppDimmerState import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.core.ui.state.rememberAppDimmerState +import com.team.prezel.core.ui.util.noRippleClickable import com.team.prezel.feature.splash.api.SplashNavKey import com.team.prezel.navigation.MAIN_NAV_ITEMS import kotlinx.collections.immutable.ImmutableSet @@ -38,9 +44,11 @@ fun PrezelApp( ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } val snackbarHostState = remember { SnackbarHostState() } + val appDimmerState = rememberAppDimmerState() CompositionLocalProvider( LocalNavigator provides navigator, + LocalAppDimmerState provides appDimmerState, LocalSnackbarHostState provides snackbarHostState, ) { DoubleBackToExitHandler(navigationState = appState.navigationState) @@ -60,44 +68,56 @@ private fun PrezelAppContent( entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = LocalNavigator.current + val appDimmerState = LocalAppDimmerState.current ObserveGlobalEvents( globalEventBus = globalEventBus, navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, ) - SharedTransitionLayout { - ProvideSharedTransitionScope(this@SharedTransitionLayout) { - val provider = remember(entryBuilders, navigator) { - entryProvider { - entryBuilders.forEach { builder -> this.builder() } + Box(modifier = Modifier.fillMaxSize()) { + SharedTransitionLayout { + ProvideSharedTransitionScope(this@SharedTransitionLayout) { + val provider = remember(entryBuilders, navigator) { + entryProvider { + entryBuilders.forEach { builder -> this.builder() } + } } - } - PrezelNavigationScaffold( - showNavigationBar = appState.shouldShowNavigationBar, - snackbarHostState = LocalSnackbarHostState.current, - navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, - ) { padding -> - NavDisplay( - entries = appState.navigationState.toEntries(provider), - onBack = navigator::goBack, - modifier = Modifier.padding(padding), - transitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - popTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - predictivePopTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - ) + PrezelNavigationScaffold( + showNavigationBar = appState.shouldShowNavigationBar, + snackbarHostState = LocalSnackbarHostState.current, + navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, + ) { padding -> + NavDisplay( + entries = appState.navigationState.toEntries(provider), + onBack = navigator::goBack, + modifier = Modifier.padding(padding), + transitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 100)) + }, + popTransitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 100)) + }, + predictivePopTransitionSpec = { + fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 100)) + }, + ) + } } } + + if (appDimmerState.isVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .background(com.team.prezel.core.designsystem.theme.PrezelTheme.colors.scrimContainer) + .noRippleClickable { appDimmerState.dismiss() }, + ) + } } } diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/state/LocalAppDimmerState.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/state/LocalAppDimmerState.kt new file mode 100644 index 00000000..53c61478 --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/state/LocalAppDimmerState.kt @@ -0,0 +1,38 @@ +package com.team.prezel.core.ui.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf + +@Stable +class AppDimmerState internal constructor() { + var isVisible by mutableStateOf(false) + private set + + private var onDismissRequest: (() -> Unit)? by mutableStateOf(null) + + fun show(onDismissRequest: () -> Unit = {}) { + this.onDismissRequest = onDismissRequest + isVisible = true + } + + fun hide() { + isVisible = false + onDismissRequest = null + } + + fun dismiss() { + onDismissRequest?.invoke() + } +} + +@Composable +fun rememberAppDimmerState(): AppDimmerState = remember { AppDimmerState() } + +val LocalAppDimmerState = staticCompositionLocalOf { + error("AppDimmerState is not provided") +} From ede2970cb80bc43420aba37f6183d82f999fdcb6 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 19:05:49 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix:=20PracticeCard=20=EB=82=B4=20D-Day?= =?UTF-8?q?=20=ED=8F=AC=ED=95=A8=20=EB=B2=94=EC=9C=84=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=B0=8F=20=ED=94=84=EB=A6=AC=EB=B7=B0=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **fix: `PracticeCard` 트래커 아이템의 종료일 포함 로직 수정** * `toTrackerItems` 함수 호출 시 `dDay`에 1일을 더하도록 변경하여, 트래커 아이템 범위에 D-Day 당일이 올바르게 포함되도록 수정했습니다. * **refactor: `PracticeCardPreview` 샘플 데이터 및 날짜 계산 로직 변경** * 프리뷰 확인을 위한 `baseDate`와 `dDay` 날짜를 업데이트했습니다. * 샘플 아이템 리스트 생성 시에도 `dDay`에 1일을 추가하여 실제 컴포넌트의 날짜 계산 방식과 일치시켰습니다. --- .../com/team/prezel/core/ui/component/PracticeCard.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt index cf1c6f3f..03b5c336 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PracticeCard.kt @@ -43,6 +43,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toPersistentList import kotlinx.datetime.DatePeriod +import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.daysUntil import kotlinx.datetime.format @@ -80,7 +81,7 @@ fun PracticeCard( onClickAction: () -> Unit = {}, ) { var startIndex by rememberSaveable(items) { mutableIntStateOf(0) } - val trackerItems = items.toTrackerItems(dDay = dDay) + val trackerItems = items.toTrackerItems(dDay = dDay.plus(1, DateTimeUnit.DAY)) val hasPreviousPage = startIndex > 0 val hasNextPage = startIndex + TRACKER_SIZE < trackerItems.size @@ -340,14 +341,14 @@ private fun Modifier.applyDashBorder(type: StampType): Modifier { @BasicPreview @Composable private fun PracticeCardPreview() { - val baseDate = LocalDate(year = 2026, month = 3, day = 20) - val dDay = LocalDate(year = 2026, month = 3, day = 30) + val baseDate = LocalDate(year = 2026, month = 5, day = 26) + val dDay = LocalDate(year = 2026, month = 6, day = 2) PrezelTheme { Box(modifier = Modifier.padding(16.dp)) { PracticeCard( dDay = dDay, - items = List(baseDate.daysUntil(dDay)) { index -> + items = List(baseDate.daysUntil(dDay.plus(1, DateTimeUnit.DAY))) { index -> PracticeCardItem( date = baseDate.plus(DatePeriod(days = index)), isPracticed = index % 2 == 0, From 174154a20134285bee5646a06facdf577e4ce2c6 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 19:06:20 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=97=B0=EC=8A=B5?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `FetchMainDataUseCase` 내 연습 기록 병렬 조회 및 결합 로직 추가** * 각 발표 데이터(`MainData`)에 해당하는 연습 기록을 `async`/`awaitAll`을 이용해 병렬로 조회하도록 개선했습니다. * 유즈케이스의 반환 타입을 `Result>`로 변경했습니다. * **refactor: 도메인 및 네트워크 모델 구조 수정** * 발표 정보와 연습 기록을 함께 담는 `MainDataWithPracticeRecords` 모델을 새롭게 정의했습니다. * `MainData` 모델에서 `accuracyScoreChange`, `scriptMatchRateChange` 필드를 제거했습니다. * `GetMainDataResponse` 네트워크 응답 모델의 필드들을 nullable로 변경하고, `PresentationMapper`에서 이에 따른 null 처리 로직을 반영했습니다. * **build: 의존성 추가 및 기타 변경 사항** * `core:domain` 모듈에 `kotlinx-datetime` 라이브러리 의존성을 추가했습니다. * `AnalyzePresentationUseCase` 실행 시 성공 로그를 출력하도록 수정했습니다. --- .../core/data/mapper/PresentationMapper.kt | 4 +- Prezel/core/domain/build.gradle.kts | 1 + .../AnalyzePresentationUseCase.kt | 23 ++++++------ .../presentation/FetchMainDataUseCase.kt | 37 ++++++++++++++++++- .../core/model/presentation/MainData.kt | 2 - .../MainDataWithPracticeRecords.kt | 14 +++++++ .../model/presentation/GetMainDataResponse.kt | 6 +-- 7 files changed, 67 insertions(+), 20 deletions(-) create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataWithPracticeRecords.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt index d381bd12..085758ec 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/mapper/PresentationMapper.kt @@ -126,9 +126,7 @@ internal fun GetMainDataResponse.toDomain(): MainData = presentationDate = LocalDate.parse(presentationDate), isPast = isPast, dDay = dDay, - accuracyScoreChange = accuracyScoreChange, - scriptMatchRateChange = scriptMatchRateChange, - growthGraph = growthGraph.map { item -> item.toDomain() }, + growthGraph = growthGraph?.map { item -> item.toDomain() }.orEmpty(), ) private fun GetMainDataResponse.GrowthGraph.toDomain(): PresentationGrowthPoint = diff --git a/Prezel/core/domain/build.gradle.kts b/Prezel/core/domain/build.gradle.kts index d7bfada0..6c742460 100644 --- a/Prezel/core/domain/build.gradle.kts +++ b/Prezel/core/domain/build.gradle.kts @@ -7,4 +7,5 @@ dependencies { implementation(projects.coreCommon) implementation(libs.javax.inject) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt index cd6ea59a..ec48cf88 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt @@ -21,15 +21,16 @@ class AnalyzePresentationUseCase @Inject constructor( scriptFilePath: String?, audioFilePath: String, ): Result = - presentationRepository.analyzePresentation( - name = name, - date = date, - category = category, - purpose = purpose, - style = style, - audience = audience, - script = script, - scriptFilePath = scriptFilePath, - audioFilePath = audioFilePath, - ) + presentationRepository + .analyzePresentation( + name = name, + date = date, + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ).onSuccess { println(date) } } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt index a19f4304..f5e84e92 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt @@ -2,10 +2,45 @@ package com.team.prezel.core.domain.usecase.presentation import com.team.prezel.core.domain.repository.presentation.PresentationRepository import com.team.prezel.core.model.presentation.MainData +import com.team.prezel.core.model.presentation.MainDataWithPracticeRecords +import com.team.prezel.core.model.presentation.PracticeRecords +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import javax.inject.Inject class FetchMainDataUseCase @Inject constructor( private val repository: PresentationRepository, ) { - suspend operator fun invoke(): Result> = repository.getMainData() + suspend operator fun invoke(): Result> = + repository.getMainData().fold( + onSuccess = { mainData -> + runCatching { + coroutineScope { + mainData + .map { data -> + async { + val practiceRecords = repository + .getPracticeRecords(presentationId = data.presentationId) + .getOrThrow() + data.toMainDataWithPracticeRecords(practiceRecords = practiceRecords) + } + }.awaitAll() + } + } + }, + onFailure = { throwable -> Result.failure(throwable) }, + ) + + private fun MainData.toMainDataWithPracticeRecords(practiceRecords: PracticeRecords): MainDataWithPracticeRecords = + MainDataWithPracticeRecords( + presentationId = presentationId, + title = title, + type = type, + presentationDate = presentationDate, + isPast = isPast, + dDay = dDay, + growthGraph = growthGraph, + practiceRecords = practiceRecords, + ) } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt index aa900450..2f966227 100644 --- a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt @@ -9,7 +9,5 @@ data class MainData( val presentationDate: LocalDate, val isPast: Boolean, val dDay: String, - val accuracyScoreChange: Int, - val scriptMatchRateChange: Int, val growthGraph: List, ) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataWithPracticeRecords.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataWithPracticeRecords.kt new file mode 100644 index 00000000..46e16217 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataWithPracticeRecords.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.model.presentation + +import kotlinx.datetime.LocalDate + +data class MainDataWithPracticeRecords( + val presentationId: Long, + val title: String, + val type: String, + val presentationDate: LocalDate, + val isPast: Boolean, + val dDay: String, + val growthGraph: List, + val practiceRecords: PracticeRecords, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt index 5fbd1865..dbf2d412 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetMainDataResponse.kt @@ -6,11 +6,11 @@ import kotlinx.serialization.Serializable @Serializable data class GetMainDataResponse( @SerialName("accuracyScoreChange") - val accuracyScoreChange: Int, + val accuracyScoreChange: Int?, @SerialName("dday") val dDay: String, @SerialName("growthGraph") - val growthGraph: List, + val growthGraph: List?, @SerialName("isPast") val isPast: Boolean, @SerialName("presentationDate") @@ -18,7 +18,7 @@ data class GetMainDataResponse( @SerialName("presentationId") val presentationId: Int, @SerialName("scriptMatchRateChange") - val scriptMatchRateChange: Int, + val scriptMatchRateChange: Int?, @SerialName("title") val title: String, @SerialName("type") From de75cf034d43835ce7b1654f2349b580e9d2869a Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 19:06:42 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=ED=99=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=B0=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **feat: 홈 화면 데이터 연동 및 상태 관리 로직 구현** * `FetchMainDataUseCase`를 연동하여 실제 데이터를 조회하도록 `HomeViewModel`을 수정했습니다. * 성장 그래프의 아이템 선택 상태를 토글하는 `ClickCardGraphItem` Intent 및 처리 로직을 추가했습니다. * `HomeUiState`에서 도메인 모델(`MainDataWithPracticeRecords`)을 UI 모델로 변환하는 매핑 로직을 구현했습니다. * **refactor: UI 데이터 모델 구조 개선** * `PresentationUiModel`을 `Past`와 `Upcoming` 타입을 가진 `sealed interface`로 개편하여 발표 상태별 데이터 구조를 명확히 했습니다. * 연습 기록 데이터를 관리하는 `PracticeRecordsUiModel`과 성장 그래프 데이터를 위한 `GrowthGraphData` 모델을 추가했습니다. * **feat: 홈 화면 UI 구성 요소 고도화** * **Floating Action Button(FAB) 및 배경 딤(Dim) 처리:** `LocalAppDimmerState`를 연동하여 FAB 확장 시 배경을 어둡게 처리하고, `Popup` 및 `PopupPositionProvider`를 통해 FAB 메뉴의 위치를 동적으로 계산하도록 개선했습니다. * **발표 상세 시트(PresentationSheet):** 기존의 단순 버튼 구조에서 `PracticeCard`(연습 기록) 및 `CardGraph`(성장 그래프)를 포함한 상세한 정보를 보여주도록 업데이트했습니다. * **발표 상태별 대응:** 과거 발표와 예정된 발표에 따라 시트 내 타이틀과 하단 콘텐츠(그래프 또는 키워드 섹션)가 다르게 노출되도록 구현했습니다. * **style: 디자인 시스템 컴포넌트 및 리소스 추가** * `PrezelFloatingButton` 및 `PrezelFloatingMenu`에 `enabled` 속성을 추가하여 상태 제어가 가능하도록 수정했습니다. * 홈 화면 하단 시트에서 사용하는 새로운 문자열 리소스를 추가했습니다. * `feature:home:impl` 모듈에 `core:domain` 의존성을 추가했습니다. --- .../button/floating/PrezelFloatingButton.kt | 2 + .../button/floating/PrezelFloatingMenu.kt | 2 + Prezel/feature/home/impl/build.gradle.kts | 2 + .../feature/home/impl/main/HomeScreen.kt | 193 +++++++++++++++--- .../feature/home/impl/main/HomeViewModel.kt | 87 ++++---- .../component/body/HomeBottomSheetTitle.kt | 18 +- .../main/component/body/PresentationSheet.kt | 62 +++--- .../main/component/head/HomeHeadSection.kt | 30 ++- .../main/component/title/PresentationHero.kt | 23 ++- .../home/impl/main/contract/HomeUiIntent.kt | 5 + .../home/impl/main/contract/HomeUiState.kt | 19 +- .../impl/main/model/GrowthGraphItemUiModel.kt | 44 ++++ .../impl/main/model/PracticeRecordsUiModel.kt | 42 ++++ .../impl/main/model/PresentationUiModel.kt | 77 ++++--- .../home/impl/src/main/res/values/strings.xml | 2 + 15 files changed, 466 insertions(+), 142 deletions(-) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PracticeRecordsUiModel.kt diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingButton.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingButton.kt index 5dcc2047..49efb45e 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingButton.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingButton.kt @@ -29,12 +29,14 @@ fun PrezelFloatingButton( modifier: Modifier = Modifier, size: ButtonSize = ButtonSize.REGULAR, hierarchy: ButtonHierarchy = ButtonHierarchy.PRIMARY, + enabled: Boolean = true, @DrawableRes openIconResId: Int = PrezelIcons.Cancel, ) { PrezelIconButton( iconResId = if (isExpanded) openIconResId else iconResId, size = size, hierarchy = hierarchy, + enabled = enabled, isRounded = true, modifier = modifier.prezelDropShadow( style = PrezelDropShadowDefaults.Regular( diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingMenu.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingMenu.kt index 1bcc5066..770968e0 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingMenu.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/floating/PrezelFloatingMenu.kt @@ -38,6 +38,7 @@ fun PrezelFloatingMenu( @DrawableRes openIconResId: Int = PrezelIcons.Cancel, size: ButtonSize = ButtonSize.REGULAR, hierarchy: ButtonHierarchy = ButtonHierarchy.PRIMARY, + enabled: Boolean = true, items: @Composable PrezelMenuScope.() -> Unit, ) { Column( @@ -64,6 +65,7 @@ fun PrezelFloatingMenu( openIconResId = openIconResId, size = size, hierarchy = hierarchy, + enabled = enabled, ) } } diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index b8e7db04..c8d37800 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -8,6 +8,8 @@ android { dependencies { implementation(projects.coreModel) + implementation(projects.coreDomain) + implementation(projects.featureAnalysisApi) implementation(projects.featureHomeApi) implementation(projects.featurePracticeApi) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index 8022c87d..c77392cb 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -1,16 +1,16 @@ package com.team.prezel.feature.home.impl.main -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,11 +19,23 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy @@ -35,6 +47,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.core.navigation.LocalNavigator +import com.team.prezel.core.ui.state.LocalAppDimmerState import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.core.ui.util.onHeightChanged import com.team.prezel.feature.home.impl.R @@ -47,12 +60,16 @@ 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.GrowthGraphData import com.team.prezel.feature.home.impl.main.model.HomeUiMessage +import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel 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 +import kotlin.math.max +import kotlin.math.roundToInt @Composable internal fun HomeScreen( @@ -91,6 +108,9 @@ internal fun HomeScreen( onClickWriteFeedback = { }, onClickVoiceRecordingAnalysis = navigateToVoiceRecordingAnalysis, onClickFileUploadAnalysis = navigateToFileUploadAnalysis, + onClickCardGraphItemIndex = { presentationId, index -> + viewModel.onIntent(HomeUiIntent.ClickCardGraphItem(presentationId = presentationId, index = index)) + }, modifier = modifier, ) } @@ -106,10 +126,36 @@ private fun HomeScreen( onClickWriteFeedback: (PresentationUiModel) -> Unit, onClickVoiceRecordingAnalysis: () -> Unit, onClickFileUploadAnalysis: () -> Unit, + onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + val appDimmerState = LocalAppDimmerState.current + val density = LocalDensity.current + val fabEndPaddingPx = with(density) { PrezelTheme.spacing.V24.roundToPx() } var isFabExpanded by remember { mutableStateOf(false) } + var collapsedFabBounds by remember { mutableStateOf(null) } + var expandedMenuBounds by remember { mutableStateOf(null) } + val onClickVoiceRecording = { + isFabExpanded = false + onClickVoiceRecordingAnalysis() + } + val onClickFileUpload = { + isFabExpanded = false + onClickFileUploadAnalysis() + } + + LaunchedEffect(isFabExpanded) { + if (isFabExpanded) { + appDimmerState.show { isFabExpanded = false } + } else { + appDimmerState.hide() + } + } + + DisposableEffect(appDimmerState) { + onDispose { appDimmerState.hide() } + } BoxWithConstraints(modifier = modifier.fillMaxSize()) { val maxScreenHeight = maxHeight @@ -125,6 +171,7 @@ private fun HomeScreen( onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, ) HomeHeadSection( @@ -134,30 +181,63 @@ private fun HomeScreen( modifier = Modifier.onHeightChanged { newHeight -> headerHeight = newHeight }, ) - if (isFabExpanded) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.32f)) - .clickable { isFabExpanded = false }, - ) - } + HomeAnalysisFloatingMenu( + isExpanded = false, + onChangeExpanded = {}, + onClickVoiceRecording = {}, + onClickFileUpload = {}, + enabled = false, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24) + .graphicsLayer { alpha = 0f } + .clearAndSetSemantics { }, + onFabPositioned = { coordinates -> collapsedFabBounds = coordinates.boundsInWindow() }, + ) HomeAnalysisFloatingMenu( - isExpanded = isFabExpanded, - onChangeExpanded = { isFabExpanded = it }, - onClickVoiceRecording = { - isFabExpanded = false - onClickVoiceRecordingAnalysis() - }, - onClickFileUpload = { - isFabExpanded = false - onClickFileUploadAnalysis() - }, + isExpanded = true, + onChangeExpanded = {}, + onClickVoiceRecording = {}, + onClickFileUpload = {}, + enabled = false, modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24), + .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24) + .graphicsLayer { alpha = 0f } + .clearAndSetSemantics { }, + onFabPositioned = { coordinates -> expandedMenuBounds = coordinates.boundsInWindow() }, ) + + if (collapsedFabBounds != null && expandedMenuBounds != null) { + val expandedMenuWidth = with(density) { expandedMenuBounds!!.width.toDp() } + val expandedMenuHeight = with(density) { expandedMenuBounds!!.height.toDp() } + + Popup( + popupPositionProvider = remember(collapsedFabBounds, expandedMenuBounds, fabEndPaddingPx) { + HomeFabPopupPositionProvider( + collapsedFabBounds = collapsedFabBounds!!, + expandedMenuBounds = expandedMenuBounds!!, + endPaddingPx = fabEndPaddingPx, + ) + }, + ) { + Box( + modifier = Modifier.requiredSize( + width = expandedMenuWidth, + height = expandedMenuHeight, + ), + contentAlignment = Alignment.BottomEnd, + ) { + HomeAnalysisFloatingMenu( + isExpanded = isFabExpanded, + onChangeExpanded = { isFabExpanded = it }, + onClickVoiceRecording = onClickVoiceRecording, + onClickFileUpload = onClickFileUpload, + ) + } + } + } } } } @@ -168,7 +248,9 @@ private fun HomeAnalysisFloatingMenu( onChangeExpanded: (Boolean) -> Unit, onClickVoiceRecording: () -> Unit, onClickFileUpload: () -> Unit, + enabled: Boolean = true, modifier: Modifier = Modifier, + onFabPositioned: ((LayoutCoordinates) -> Unit)? = null, ) { PrezelFloatingMenu( isExpanded = isExpanded, @@ -177,7 +259,14 @@ private fun HomeAnalysisFloatingMenu( openIconResId = PrezelIcons.Cancel, size = ButtonSize.REGULAR, hierarchy = ButtonHierarchy.PRIMARY, - modifier = modifier, + enabled = enabled, + modifier = modifier.then( + if (onFabPositioned == null) { + Modifier + } else { + Modifier.onGloballyPositioned(onFabPositioned) + }, + ), ) { MenuItem( label = stringResource(R.string.feature_home_impl_analysis_voice_recording), @@ -192,6 +281,27 @@ private fun HomeAnalysisFloatingMenu( } } +private class HomeFabPopupPositionProvider( + private val collapsedFabBounds: Rect, + private val expandedMenuBounds: Rect, + private val endPaddingPx: Int, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = windowSize.width - popupContentSize.width - endPaddingPx + val y = (collapsedFabBounds.bottom - expandedMenuBounds.height).roundToInt() + + return IntOffset( + x = max(0, x), + y = max(0, y), + ) + } +} + @Composable private fun HomeContent( uiState: HomeUiState, @@ -202,6 +312,7 @@ private fun HomeContent( onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, ) { when (uiState) { HomeUiState.Loading -> Unit @@ -223,6 +334,7 @@ private fun HomeContent( onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(uiState.presentation.id, index) }, ) } @@ -235,6 +347,7 @@ private fun HomeContent( onClickPracticeRecording = onClickPracticeRecording, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, ) } } @@ -269,21 +382,21 @@ private fun HomeSingleContent( onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickCardGraphItemIndex: (index: Int) -> Unit, ) { - val presentation = uiState.presentation - HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, sheetContent = { PresentationSheet( - practiceCount = presentation.practiceCount, + presentation = uiState.presentation, onClickPracticeRecording = onClickPracticeRecording, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, ) }, heroContent = { PresentationHero( - presentation = presentation, + presentation = uiState.presentation, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, ) @@ -300,6 +413,7 @@ private fun HomeMultipleContent( onClickPracticeRecording: () -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, ) { HorizontalPager( state = pagerState, @@ -315,8 +429,9 @@ private fun HomeMultipleContent( headerHeight = headerHeight, sheetContent = { PresentationSheet( - practiceCount = presentation.practiceCount, + presentation = presentation, onClickPracticeRecording = onClickPracticeRecording, + onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(presentation.id, index) }, ) }, heroContent = { @@ -344,6 +459,7 @@ private fun HomeScreenEmptyPreview() { onClickWriteFeedback = { }, onClickVoiceRecordingAnalysis = { }, onClickFileUploadAnalysis = { }, + onClickCardGraphItemIndex = { presentationId, index -> }, ) } } @@ -352,12 +468,18 @@ private fun HomeScreenEmptyPreview() { @Composable private fun HomeScreenSinglePreview() { val uiState = HomeUiState.SingleContent( - presentation = PresentationUiModel( + presentation = PresentationUiModel.Past( id = 1L, category = Category.OFFER, title = "날짜 지난 발표제목", date = LocalDate(2026, 4, 3), - dDay = -1, + dDay = "+1", + practiceRecords = PracticeRecordsUiModel( + practicedDates = listOf(LocalDate(2026, 4, 1), LocalDate(2026, 4, 3)), + startDate = LocalDate(2026, 3, 30), + endDate = LocalDate(2026, 4, 3), + ), + growthGraphData = GrowthGraphData(items = emptyList(), selectedItemIndex = 0), ), ) PrezelTheme { @@ -370,6 +492,7 @@ private fun HomeScreenSinglePreview() { onClickWriteFeedback = { }, onClickVoiceRecordingAnalysis = { }, onClickFileUploadAnalysis = { }, + onClickCardGraphItemIndex = { presentationId, index -> }, ) } } @@ -379,12 +502,17 @@ private fun HomeScreenSinglePreview() { private fun HomeScreenMultiplePreview() { val uiState = HomeUiState.MultipleContent( List(3) { index -> - PresentationUiModel( + PresentationUiModel.Upcoming( id = index.toLong(), category = Category.EDUCATION, title = "공백포함둘에서열글자", date = LocalDate(2026, 4, 10 + index), - dDay = index, + dDay = "-$index", + practiceRecords = PracticeRecordsUiModel( + practicedDates = listOf(LocalDate(2026, 4, 10 + index)), + startDate = LocalDate(2026, 4, 7 + index), + endDate = LocalDate(2026, 4, 10 + index), + ), ) }.toPersistentList(), ) @@ -398,6 +526,7 @@ private fun HomeScreenMultiplePreview() { onClickWriteFeedback = { }, onClickVoiceRecordingAnalysis = { }, onClickFileUploadAnalysis = { }, + onClickCardGraphItemIndex = { presentationId, index -> }, ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt index 11f5f06a..cf469119 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeViewModel.kt @@ -1,65 +1,76 @@ package com.team.prezel.feature.home.impl.main import androidx.lifecycle.viewModelScope -import com.team.prezel.core.model.presentation.Audience -import com.team.prezel.core.model.presentation.Category -import com.team.prezel.core.model.presentation.PresentationInfo -import com.team.prezel.core.model.presentation.Purpose -import com.team.prezel.core.model.presentation.Style +import com.team.prezel.core.domain.usecase.presentation.FetchMainDataUseCase import com.team.prezel.core.ui.base.BaseViewModel 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.contract.HomeUiState.Companion.toUiState +import com.team.prezel.feature.home.impl.main.model.HomeUiMessage import com.team.prezel.feature.home.impl.main.model.PresentationUiModel -import com.team.prezel.feature.home.impl.main.model.PresentationUiModel.Companion.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch -import kotlinx.datetime.LocalDate import javax.inject.Inject @HiltViewModel -internal class HomeViewModel @Inject constructor() : BaseViewModel(HomeUiState.Loading) { +internal class HomeViewModel @Inject constructor( + private val fetchMainDataUseCase: FetchMainDataUseCase, +) : BaseViewModel(HomeUiState.Loading) { override fun onIntent(intent: HomeUiIntent) { when (intent) { HomeUiIntent.FetchData -> fetchData() + is HomeUiIntent.ClickCardGraphItem -> updateCardGraphData(intent.presentationId, intent.index) } } private fun fetchData() { viewModelScope.launch { - val nickname = "프레즐" - val presentations = getPresentations() + fetchMainDataUseCase() + .onSuccess { data -> updateState { data.toUiState() } } + .onFailure { sendEffect(HomeUiEffect.ShowMessage(HomeUiMessage.FETCH_DATA_FAILED)) } + } + } + + private fun updateCardGraphData( + presentationId: Long, + index: Int, + ) { + updateState { + when (this) { + HomeUiState.Loading, is HomeUiState.Empty -> this + is HomeUiState.SingleContent -> { + val currentPresentation = presentation as? PresentationUiModel.Past ?: return@updateState this + if (currentPresentation.id != presentationId) return@updateState this + + copy( + presentation = currentPresentation.copy( + growthGraphData = currentPresentation.growthGraphData.copy( + selectedItemIndex = currentPresentation.growthGraphData.selectedItemIndex.toggle(index), + ), + ), + ) + } + + is HomeUiState.MultipleContent -> { + copy( + presentations = presentations + .map { presentation -> + val currentPresentation = presentation as? PresentationUiModel.Past ?: return@map presentation + if (currentPresentation.id != presentationId) return@map presentation - updateState { - HomeUiState.from( - presentations = presentations, - nickname = nickname, - ) + currentPresentation.copy( + growthGraphData = currentPresentation.growthGraphData.copy( + selectedItemIndex = currentPresentation.growthGraphData.selectedItemIndex.toggle(index), + ), + ) + }.toImmutableList(), + ) + } } } } - private fun getPresentations(): List = - listOf( - PresentationInfo( - id = 1L, - title = "신규 서비스 제안 발표", - presentationDate = LocalDate(2026, 4, 10), - category = Category.OFFER, - purpose = Purpose.INFO, - style = Style.FORMAL, - audience = Audience.GENERAL, - dDay = "10", - ), - PresentationInfo( - id = 2L, - title = "주간 업무 공유", - presentationDate = LocalDate(2026, 4, 12), - category = Category.WORK, - purpose = Purpose.INFO, - style = Style.FORMAL, - audience = Audience.GENERAL, - dDay = "10", - ), - ).map { presentation -> presentation.toUiModel() } + private fun Int?.toggle(index: Int): Int? = if (this == index) null else index } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt index ee4efc4c..b04bae1e 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/HomeBottomSheetTitle.kt @@ -1,7 +1,10 @@ package com.team.prezel.feature.home.impl.main.component.body 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.material3.Text import androidx.compose.runtime.Composable @@ -15,12 +18,15 @@ internal fun HomeBottomSheetTitle( title: String, modifier: Modifier = Modifier, ) { - Text( - modifier = modifier.fillMaxWidth(), - text = title, - color = PrezelTheme.colors.textLarge, - style = PrezelTheme.typography.body2Bold, - ) + Column(modifier = modifier.fillMaxWidth()) { + Text( + modifier = Modifier.fillMaxWidth(), + text = title, + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body2Bold, + ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + } } @BasicPreview diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index f450f286..654d55af 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -9,36 +9,51 @@ 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.core.ui.component.PracticeCard +import com.team.prezel.core.ui.component.graph.CardGraph import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.datetime.LocalDate @Composable internal fun PresentationSheet( - practiceCount: Int, + presentation: PresentationUiModel, onClickPracticeRecording: () -> Unit, + onClickCardGraphItemIndex: (index: Int) -> Unit, modifier: Modifier = Modifier, ) { - val itemModifier = Modifier.padding(horizontal = PrezelTheme.spacing.V20) - HomeBottomSheetContent( modifier = modifier, - contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32), + contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), ) { - 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, + HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_content_title, presentation.practiceCount)) + PracticeCard( + dDay = presentation.practiceRecords.endDate, + items = presentation.practiceRecords.practices, + showActionButton = !presentation.isPastPresentation, + onClickAction = onClickPracticeRecording, ) + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + + when (presentation) { + is PresentationUiModel.Past -> { + HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_past_graph_title)) + CardGraph( + items = presentation.growthGraphData.graphItems, + selectedItemIndex = presentation.growthGraphData.selectedItemIndex, + onSelectItem = { index -> onClickCardGraphItemIndex(index) }, + ) + } + + is PresentationUiModel.Upcoming -> { + HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_upcoming_keywords_title)) + } + } + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V36)) } } @@ -46,23 +61,24 @@ internal fun PresentationSheet( @Composable private fun PresentationContentPreview() { PrezelTheme { - val presentation = PresentationUiModel( + val presentation = PresentationUiModel.Upcoming( id = 1L, category = Category.OFFER, title = "설득하는 발표", date = LocalDate(2026, 10, 1), - dDay = 3, - practiceCount = 5, + dDay = "-3", + practiceRecords = PracticeRecordsUiModel( + practicedDates = List(5) { LocalDate(2026, 9, 26 + it) }, + startDate = LocalDate(2026, 9, 26), + endDate = LocalDate(2026, 10, 1), + ), ) - Box( - modifier = Modifier - .height(100.dp) - .padding(top = 16.dp), - ) { + Box(modifier = Modifier.padding(top = 16.dp)) { PresentationSheet( - practiceCount = presentation.practiceCount, + presentation = presentation, onClickPracticeRecording = {}, + onClickCardGraphItemIndex = {}, ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt index 6b9a814d..8a88d0c4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/head/HomeHeadSection.kt @@ -18,6 +18,8 @@ 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.main.contract.HomeUiState +import com.team.prezel.feature.home.impl.main.model.GrowthGraphData +import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.LocalDate @@ -54,12 +56,17 @@ internal fun HomeHeadSection( @Composable private fun HomeHeadSectionSinglePreview() { val uiState = HomeUiState.SingleContent( - presentation = PresentationUiModel( + presentation = PresentationUiModel.Upcoming( id = 1L, category = Category.EDUCATION, title = "발표 1", date = LocalDate(2024, 1, 1), - dDay = 0, + dDay = "0", + practiceRecords = PracticeRecordsUiModel( + practicedDates = emptyList(), + startDate = LocalDate(2023, 12, 28), + endDate = LocalDate(2024, 1, 1), + ), ), ) val pagerState = rememberPagerState(0) { uiState.presentationCount() } @@ -78,19 +85,30 @@ private fun HomeHeadSectionSinglePreview() { private fun HomeHeadSectionMultiplePreview() { val uiState = HomeUiState.MultipleContent( presentations = listOf( - PresentationUiModel( + PresentationUiModel.Upcoming( id = 1L, category = Category.EDUCATION, title = "발표 1", date = LocalDate(2024, 1, 1), - dDay = 0, + dDay = "0", + practiceRecords = PracticeRecordsUiModel( + practicedDates = emptyList(), + startDate = LocalDate(2023, 12, 28), + endDate = LocalDate(2024, 1, 1), + ), ), - PresentationUiModel( + PresentationUiModel.Past( id = 2L, category = Category.EVENT, title = "발표 2", date = LocalDate(2024, 1, 2), - dDay = 1, + dDay = "+1", + practiceRecords = PracticeRecordsUiModel( + practicedDates = listOf(LocalDate(2024, 1, 2)), + startDate = LocalDate(2023, 12, 30), + endDate = LocalDate(2024, 1, 2), + ), + growthGraphData = GrowthGraphData(items = emptyList(), selectedItemIndex = 0), ), ).toImmutableList(), ) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt index b921f0a1..b4ad0866 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/title/PresentationHero.kt @@ -17,6 +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.main.model.GrowthGraphData +import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import kotlinx.datetime.LocalDate import kotlinx.datetime.number @@ -91,7 +93,7 @@ private fun HomePresentationTitleRow(presentation: PresentationUiModel) { style = PrezelTheme.typography.title1Bold, ) Text( - text = presentation.dDayLabel, + text = presentation.dDay, color = PrezelTheme.colors.interactiveRegular, style = PrezelTheme.typography.title1ExtraBold, ) @@ -121,12 +123,17 @@ private fun Category.backgroundResId(): Int = private fun HomePresentationPagePreview() { PrezelTheme { PresentationHero( - presentation = PresentationUiModel( + presentation = PresentationUiModel.Upcoming( id = 1L, category = Category.OFFER, title = "설득하는 발표", date = LocalDate(2026, 10, 1), - dDay = 3, + dDay = "-3", + practiceRecords = PracticeRecordsUiModel( + practicedDates = listOf(LocalDate(2026, 9, 28)), + startDate = LocalDate(2026, 9, 26), + endDate = LocalDate(2026, 10, 1), + ), ), onClickAnalyzePresentation = {}, onClickWriteFeedback = {}, @@ -139,12 +146,18 @@ private fun HomePresentationPagePreview() { private fun HomePresentationPagePastPreview() { PrezelTheme { PresentationHero( - presentation = PresentationUiModel( + presentation = PresentationUiModel.Past( id = 2L, category = Category.EDUCATION, title = "교육 발표", date = LocalDate(2026, 9, 20), - dDay = -5, + dDay = "+5", + practiceRecords = PracticeRecordsUiModel( + practicedDates = listOf(LocalDate(2026, 9, 18), LocalDate(2026, 9, 19)), + startDate = LocalDate(2026, 9, 15), + endDate = LocalDate(2026, 9, 20), + ), + growthGraphData = GrowthGraphData(items = emptyList(), selectedItemIndex = 0), ), onClickAnalyzePresentation = {}, onClickWriteFeedback = {}, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt index cc2fc967..b3b99f69 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiIntent.kt @@ -4,4 +4,9 @@ import com.team.prezel.core.ui.base.UiIntent internal sealed interface HomeUiIntent : UiIntent { data object FetchData : HomeUiIntent + + data class ClickCardGraphItem( + val presentationId: Long, + val index: Int, + ) : HomeUiIntent } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt index cf6b5cd9..6d2a6523 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt @@ -1,8 +1,10 @@ package com.team.prezel.feature.home.impl.main.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.MainDataWithPracticeRecords import com.team.prezel.core.ui.base.UiState import com.team.prezel.feature.home.impl.main.model.PresentationUiModel +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel.Companion.toUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -21,7 +23,7 @@ internal sealed interface HomeUiState : UiState { data class MultipleContent( val presentations: ImmutableList, ) : HomeUiState { - val dDayLabels: ImmutableList = presentations.map(PresentationUiModel::dDayLabel).toImmutableList() + val dDayLabels: ImmutableList = presentations.map(PresentationUiModel::dDay).toImmutableList() } fun presentationCount(): Int = @@ -33,14 +35,13 @@ internal sealed interface HomeUiState : UiState { } companion object { - fun from( - presentations: List, - nickname: String, - ): HomeUiState = - when (presentations.size) { - 0 -> Empty(nickname = nickname) - 1 -> SingleContent(presentation = presentations.first()) - else -> MultipleContent(presentations = presentations.toImmutableList()) + fun List.toUiState(): HomeUiState { + val uiModels = map { data -> data.toUiModel() } + return when (size) { + 0 -> Empty(nickname = "TEMP") + 1 -> SingleContent(presentation = uiModels.first()) + else -> MultipleContent(presentations = uiModels.toImmutableList()) } + } } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt new file mode 100644 index 00000000..f8088c50 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt @@ -0,0 +1,44 @@ +package com.team.prezel.feature.home.impl.main.model + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.PresentationGrowthPoint +import com.team.prezel.core.ui.component.graph.CardGraphItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Immutable +internal data class GrowthGraphItemUiModel( + val attempt: Int, + val accuracyScore: Double, + val scriptMatchRate: Double, +) { + val graphItem: CardGraphItem + get() = CardGraphItem( + speech = accuracyScore.toGraphRatio(), + scriptMatch = scriptMatchRate.toGraphRatio(), + ) +} + +@Immutable +internal data class GrowthGraphData( + val items: List, + val selectedItemIndex: Int? = null, +) { + val selectedItem: GrowthGraphItemUiModel? = selectedItemIndex?.let { index -> items.getOrNull(index) } + val graphItems: ImmutableList = items.map(GrowthGraphItemUiModel::graphItem).toImmutableList() + + companion object { + fun List.toUiModel(): GrowthGraphData = + GrowthGraphData( + items = this.map { item -> + GrowthGraphItemUiModel( + attempt = item.attempt, + accuracyScore = item.accuracyScore, + scriptMatchRate = item.scriptMatchRate, + ) + }, + ) + } +} + +private fun Double.toGraphRatio(): Float = (this / 100.0).coerceIn(0.0, 1.0).toFloat() diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PracticeRecordsUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PracticeRecordsUiModel.kt new file mode 100644 index 00000000..894bca07 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PracticeRecordsUiModel.kt @@ -0,0 +1,42 @@ +package com.team.prezel.feature.home.impl.main.model + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.PracticeRecords +import com.team.prezel.core.ui.component.PracticeCardItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus + +@Immutable +internal data class PracticeRecordsUiModel( + private val practicedDates: List, + val startDate: LocalDate, + val endDate: LocalDate, +) { + val practices: ImmutableList = buildList { + val practicedDateSet = practicedDates.toSet() + var currentDate = startDate + + while (currentDate <= endDate) { + add( + PracticeCardItem( + date = currentDate, + isPracticed = currentDate in practicedDateSet, + ), + ) + + currentDate = currentDate.plus(DatePeriod(days = 1)) + } + }.toImmutableList() + + companion object { + fun PracticeRecords.toUiModel(): PracticeRecordsUiModel = + PracticeRecordsUiModel( + practicedDates = dates, + startDate = startDate, + endDate = endDate, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt index 2febadb1..c540703c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/PresentationUiModel.kt @@ -2,34 +2,65 @@ package com.team.prezel.feature.home.impl.main.model import androidx.compose.runtime.Immutable import com.team.prezel.core.model.presentation.Category -import com.team.prezel.core.model.presentation.PresentationInfo +import com.team.prezel.core.model.presentation.MainDataWithPracticeRecords +import com.team.prezel.core.ui.component.PracticeCardItem +import com.team.prezel.feature.home.impl.main.model.GrowthGraphData.Companion.toUiModel +import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel.Companion.toUiModel import kotlinx.datetime.LocalDate @Immutable -internal data class PresentationUiModel( - val id: Long, - val category: Category, - val title: String, - val date: LocalDate, - val dDay: Int, - val practiceCount: Int = 0, -) { - val isPastPresentation: Boolean = dDay < 0 +internal sealed interface PresentationUiModel { + val id: Long + val category: Category + val title: String + val date: LocalDate + val dDay: String + val practiceRecords: PracticeRecordsUiModel + val practiceCount: Int + get() = practiceRecords.practices.count(PracticeCardItem::isPracticed) + val isPastPresentation: Boolean + get() = this is Past - val dDayLabel: String = when (dDay) { - 0 -> "D-Day" - in Int.MIN_VALUE..-1 -> "D+${-dDay}" - else -> "D-$dDay" - } + data class Past( + override val id: Long, + override val category: Category, + override val title: String, + override val date: LocalDate, + override val dDay: String, + override val practiceRecords: PracticeRecordsUiModel, + val growthGraphData: GrowthGraphData, + ) : PresentationUiModel + + data class Upcoming( + override val id: Long, + override val category: Category, + override val title: String, + override val date: LocalDate, + override val dDay: String, + override val practiceRecords: PracticeRecordsUiModel, + ) : PresentationUiModel companion object { - fun PresentationInfo.toUiModel(): PresentationUiModel = - PresentationUiModel( - id = id, - category = category, - title = title, - date = presentationDate, - dDay = dDay.toIntOrNull() ?: -1, - ) + fun MainDataWithPracticeRecords.toUiModel(): PresentationUiModel = + if (isPast) { + Past( + id = presentationId, + category = Category.from(type), + title = title, + date = presentationDate, + dDay = dDay, + practiceRecords = practiceRecords.toUiModel(), + growthGraphData = growthGraph.toUiModel(), + ) + } else { + Upcoming( + id = presentationId, + category = Category.from(type), + title = title, + date = presentationDate, + dDay = dDay, + practiceRecords = practiceRecords.toUiModel(), + ) + } } } 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 4a7d40fc..a2feca9b 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ 지금부터 연습해보세요 지금까지 %1$d번 연습했어요 + 나의 발표 변화를 확인해보세요 + 발표 흐름을 키워드별로 정리해보세요 안녕하세요 %1$s님! 어떤 발표를 앞두고 있나요? %1$d년 %2$02d월 %3$02d일 From 0cb51b19e3a83cfc1459ed974e312d7f66871dfb Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 19:31:21 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20UI=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B6=84=EC=84=9D=20FAB=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `HomeScreen` 컴포저블 분리 및 구조화** * `HomeScreen.kt`의 거대한 UI 로직을 `HomeScreenContent`와 `HomeAnalysisFabOverlay`로 분리하여 가독성과 유지보수성을 높였습니다. * 홈 화면의 상태(Loading, Empty, Content)에 따른 레이아웃 처리 로직을 `HomeScreenContent`로 통합했습니다. * `HomeScreen` 프리뷰 코드에서 `CompositionLocalProvider`를 통해 필요한 상태를 주입하도록 개선했습니다. * **feat: `HomeAnalysisFabOverlay` 컴포넌트 추가** * 음성 녹음 분석 및 파일 업로드 분석 기능을 제공하는 확장형 플로팅 메뉴를 구현했습니다. * `PopupPositionProvider`를 사용하여 메뉴의 확장/축소 상태에 따른 동적 팝업 위치 계산 로직을 적용했습니다. * `LocalAppDimmerState`와 연동하여 메뉴 확장 시 배경 딤 처리가 적용되도록 구현했습니다. * **refactor: `PrezelApp` 레이아웃 및 네비게이션 로직 정리** * `PrezelApp`의 UI 구조를 `AppNavigationContent`와 `AppDimmerOverlay` 컴포저블로 분리하여 역할을 명확히 했습니다. * 반복되는 네비게이션 애니메이션 설정을 `defaultPrezelNavTransition` 함수로 공통화했습니다. * **refactor: 데이터 소스 및 UI 모델 정리** * `PresentationRemoteDataSourceImpl`에서 파일 처리를 위한 헬퍼 함수들을 파일 수준의 프라이빗 함수로 이동하여 클래스 내부 로직을 단순화했습니다. * `GrowthGraphData` 모델에서 사용되지 않는 `selectedItem` 프로퍼티를 제거했습니다. * **fix: UI 텍스트 및 프리뷰 데이터 수정** * `PresentationSheet`의 D-Day 표기 형식을 `-3`에서 `D-3`으로 수정했습니다. * `HomeScreen` 프리뷰에서 그래프 데이터가 올바르게 표시되도록 샘플 데이터를 보강했습니다. --- .../main/java/com/team/prezel/ui/PrezelApp.kt | 90 ++-- .../PresentationRemoteDataSourceImpl.kt | 74 +-- .../feature/home/impl/main/HomeScreen.kt | 448 ++---------------- .../main/component/HomeAnalysisFabOverlay.kt | 228 +++++++++ .../impl/main/component/HomeScreenContent.kt | 208 ++++++++ .../main/component/body/PresentationSheet.kt | 2 +- .../impl/main/model/GrowthGraphItemUiModel.kt | 1 - 7 files changed, 562 insertions(+), 489 deletions(-) create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index 64b1ad86..a87e27c8 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -1,5 +1,6 @@ package com.team.prezel.ui +import androidx.compose.animation.ContentTransform import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -24,6 +25,7 @@ import com.team.prezel.core.common.event.GlobalEvent import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold import com.team.prezel.core.designsystem.component.PrezelNavigationScope +import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope @@ -78,49 +80,65 @@ private fun PrezelAppContent( Box(modifier = Modifier.fillMaxSize()) { SharedTransitionLayout { ProvideSharedTransitionScope(this@SharedTransitionLayout) { - val provider = remember(entryBuilders, navigator) { - entryProvider { - entryBuilders.forEach { builder -> this.builder() } - } - } - - PrezelNavigationScaffold( - showNavigationBar = appState.shouldShowNavigationBar, - snackbarHostState = LocalSnackbarHostState.current, - navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, - ) { padding -> - NavDisplay( - entries = appState.navigationState.toEntries(provider), - onBack = navigator::goBack, - modifier = Modifier.padding(padding), - transitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - popTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - predictivePopTransitionSpec = { - fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith - fadeOut(animationSpec = tween(durationMillis = 100)) - }, - ) - } + AppNavigationContent( + appState = appState, + entryBuilders = entryBuilders, + navigator = navigator, + ) } } - if (appDimmerState.isVisible) { - Box( - modifier = Modifier - .fillMaxSize() - .background(com.team.prezel.core.designsystem.theme.PrezelTheme.colors.scrimContainer) - .noRippleClickable { appDimmerState.dismiss() }, - ) + AppDimmerOverlay(isVisible = appDimmerState.isVisible, onDismiss = appDimmerState::dismiss) + } +} + +@Composable +private fun AppNavigationContent( + appState: PrezelAppState, + entryBuilders: ImmutableSet.() -> Unit>, + navigator: Navigator, +) { + val provider = remember(entryBuilders, navigator) { + entryProvider { + entryBuilders.forEach { builder -> this.builder() } } } + + PrezelNavigationScaffold( + showNavigationBar = appState.shouldShowNavigationBar, + snackbarHostState = LocalSnackbarHostState.current, + navigationItems = { AppNavigationItems(appState = appState, navigateToKey = navigator::navigate) }, + ) { padding -> + NavDisplay( + entries = appState.navigationState.toEntries(provider), + onBack = navigator::goBack, + modifier = Modifier.padding(padding), + transitionSpec = { defaultPrezelNavTransition() }, + popTransitionSpec = { defaultPrezelNavTransition() }, + predictivePopTransitionSpec = { _: Int -> defaultPrezelNavTransition() }, + ) + } } +@Composable +private fun AppDimmerOverlay( + isVisible: Boolean, + onDismiss: () -> Unit, +) { + if (!isVisible) return + + Box( + modifier = Modifier + .fillMaxSize() + .background(PrezelTheme.colors.scrimContainer) + .noRippleClickable(onClick = onDismiss), + ) +} + +private fun defaultPrezelNavTransition(): ContentTransform = + fadeIn(animationSpec = tween(durationMillis = 100)) togetherWith + fadeOut(animationSpec = tween(durationMillis = 100)) + @Composable private fun ObserveGlobalEvents( globalEventBus: GlobalEventBus, diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt index 65c5c4fa..87bd7a9e 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/PresentationRemoteDataSourceImpl.kt @@ -90,44 +90,44 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( presentationService.getPracticeRecords(presentationId = presentationId).requireData() override suspend fun getMainData(): List = presentationService.getMainData().requireData() +} - private fun String.toAudioMultipart(): MultiPartFormDataContent = - MultiPartFormDataContent( - formData { - appendAudioPart(this@toAudioMultipart) - }, - ) - - private fun FormBuilder.appendAudioPart(audioFilePath: String) { - val audioFile = File(audioFilePath) - - append( - key = "audio", - value = audioFile.toChannelProvider(), - headers = Headers.build { - append(HttpHeaders.ContentType, "audio/${audioFile.extension}") - append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") - }, - ) - } +private fun String.toAudioMultipart(): MultiPartFormDataContent = + MultiPartFormDataContent( + formData { + appendAudioPart(this@toAudioMultipart) + }, + ) + +private fun FormBuilder.appendAudioPart(audioFilePath: String) { + val audioFile = File(audioFilePath) + + append( + key = "audio", + value = audioFile.toChannelProvider(), + headers = Headers.build { + append(HttpHeaders.ContentType, "audio/${audioFile.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${audioFile.name}\"") + }, + ) +} - private fun FormBuilder.appendScriptPart(scriptFilePath: String) { - val file = File(scriptFilePath) +private fun FormBuilder.appendScriptPart(scriptFilePath: String) { + val file = File(scriptFilePath) + + append( + key = "scriptFile", + value = file.toChannelProvider(), + headers = Headers.build { + append(HttpHeaders.ContentType, "text/${file.extension}") + append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") + }, + ) +} - append( - key = "scriptFile", - value = file.toChannelProvider(), - headers = Headers.build { - append(HttpHeaders.ContentType, "text/${file.extension}") - append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") - }, - ) +private fun File.toChannelProvider(): ChannelProvider = + ChannelProvider(size = length()) { + require(exists()) { "파일이 존재하지 않습니다: $path" } + require(canRead()) { "파일을 읽을 수 없습니다: $path" } + inputStream().toByteReadChannel() } - - private fun File.toChannelProvider(): ChannelProvider = - ChannelProvider(size = length()) { - require(exists()) { "파일이 존재하지 않습니다: $path" } - require(canRead()) { "파일을 읽을 수 없습니다: $path" } - inputStream().toByteReadChannel() - } -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index c77392cb..f6a7a3aa 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -1,75 +1,35 @@ package com.team.prezel.feature.home.impl.main -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -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.floating.PrezelFloatingMenu import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar -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.model.presentation.Category import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.ui.state.LocalAppDimmerState import com.team.prezel.core.ui.state.LocalSnackbarHostState -import com.team.prezel.core.ui.util.onHeightChanged +import com.team.prezel.core.ui.state.rememberAppDimmerState 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.component.HomeScreenContent 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.GrowthGraphData +import com.team.prezel.feature.home.impl.main.model.GrowthGraphItemUiModel import com.team.prezel.feature.home.impl.main.model.HomeUiMessage import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel 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 -import kotlin.math.max -import kotlin.math.roundToInt @Composable internal fun HomeScreen( @@ -99,7 +59,7 @@ internal fun HomeScreen( } } - HomeScreen( + HomeScreenContent( uiState = uiState, pagerState = pagerState, onClickAddPresentation = { }, @@ -115,353 +75,11 @@ internal fun HomeScreen( ) } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HomeScreen( - uiState: HomeUiState, - pagerState: PagerState, - onClickAddPresentation: () -> Unit, - onClickPracticeRecording: () -> Unit, - onClickAnalyzePresentation: (PresentationUiModel) -> Unit, - onClickWriteFeedback: (PresentationUiModel) -> Unit, - onClickVoiceRecordingAnalysis: () -> Unit, - onClickFileUploadAnalysis: () -> Unit, - onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, - modifier: Modifier = Modifier, -) { - val scope = rememberCoroutineScope() - val appDimmerState = LocalAppDimmerState.current - val density = LocalDensity.current - val fabEndPaddingPx = with(density) { PrezelTheme.spacing.V24.roundToPx() } - var isFabExpanded by remember { mutableStateOf(false) } - var collapsedFabBounds by remember { mutableStateOf(null) } - var expandedMenuBounds by remember { mutableStateOf(null) } - val onClickVoiceRecording = { - isFabExpanded = false - onClickVoiceRecordingAnalysis() - } - val onClickFileUpload = { - isFabExpanded = false - onClickFileUploadAnalysis() - } - - LaunchedEffect(isFabExpanded) { - if (isFabExpanded) { - appDimmerState.show { isFabExpanded = false } - } else { - appDimmerState.hide() - } - } - - DisposableEffect(appDimmerState) { - onDispose { appDimmerState.hide() } - } - - BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val maxScreenHeight = maxHeight - var headerHeight by remember { mutableStateOf(0.dp) } - - Box(modifier = Modifier.fillMaxSize()) { - HomeContent( - uiState = uiState, - pagerState = pagerState, - maxHeight = maxScreenHeight, - headerHeight = headerHeight, - onClickAddPresentation = onClickAddPresentation, - onClickPracticeRecording = onClickPracticeRecording, - onClickAnalyzePresentation = onClickAnalyzePresentation, - onClickWriteFeedback = onClickWriteFeedback, - onClickCardGraphItemIndex = onClickCardGraphItemIndex, - ) - - HomeHeadSection( - uiState = uiState, - pagerState = pagerState, - onClickTab = { pageIndex -> scope.launch { pagerState.scrollToPage(pageIndex) } }, - modifier = Modifier.onHeightChanged { newHeight -> headerHeight = newHeight }, - ) - - HomeAnalysisFloatingMenu( - isExpanded = false, - onChangeExpanded = {}, - onClickVoiceRecording = {}, - onClickFileUpload = {}, - enabled = false, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24) - .graphicsLayer { alpha = 0f } - .clearAndSetSemantics { }, - onFabPositioned = { coordinates -> collapsedFabBounds = coordinates.boundsInWindow() }, - ) - - HomeAnalysisFloatingMenu( - isExpanded = true, - onChangeExpanded = {}, - onClickVoiceRecording = {}, - onClickFileUpload = {}, - enabled = false, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24) - .graphicsLayer { alpha = 0f } - .clearAndSetSemantics { }, - onFabPositioned = { coordinates -> expandedMenuBounds = coordinates.boundsInWindow() }, - ) - - if (collapsedFabBounds != null && expandedMenuBounds != null) { - val expandedMenuWidth = with(density) { expandedMenuBounds!!.width.toDp() } - val expandedMenuHeight = with(density) { expandedMenuBounds!!.height.toDp() } - - Popup( - popupPositionProvider = remember(collapsedFabBounds, expandedMenuBounds, fabEndPaddingPx) { - HomeFabPopupPositionProvider( - collapsedFabBounds = collapsedFabBounds!!, - expandedMenuBounds = expandedMenuBounds!!, - endPaddingPx = fabEndPaddingPx, - ) - }, - ) { - Box( - modifier = Modifier.requiredSize( - width = expandedMenuWidth, - height = expandedMenuHeight, - ), - contentAlignment = Alignment.BottomEnd, - ) { - HomeAnalysisFloatingMenu( - isExpanded = isFabExpanded, - onChangeExpanded = { isFabExpanded = it }, - onClickVoiceRecording = onClickVoiceRecording, - onClickFileUpload = onClickFileUpload, - ) - } - } - } - } - } -} - -@Composable -private fun HomeAnalysisFloatingMenu( - isExpanded: Boolean, - onChangeExpanded: (Boolean) -> Unit, - onClickVoiceRecording: () -> Unit, - onClickFileUpload: () -> Unit, - enabled: Boolean = true, - modifier: Modifier = Modifier, - onFabPositioned: ((LayoutCoordinates) -> Unit)? = null, -) { - PrezelFloatingMenu( - isExpanded = isExpanded, - onChangeExpanded = onChangeExpanded, - iconResId = PrezelIcons.Plus, - openIconResId = PrezelIcons.Cancel, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.PRIMARY, - enabled = enabled, - modifier = modifier.then( - if (onFabPositioned == null) { - Modifier - } else { - Modifier.onGloballyPositioned(onFabPositioned) - }, - ), - ) { - MenuItem( - label = stringResource(R.string.feature_home_impl_analysis_voice_recording), - iconResId = PrezelIcons.Mic, - onClick = onClickVoiceRecording, - ) - MenuItem( - label = stringResource(R.string.feature_home_impl_analysis_file_upload), - iconResId = PrezelIcons.Folder, - onClick = onClickFileUpload, - ) - } -} - -private class HomeFabPopupPositionProvider( - private val collapsedFabBounds: Rect, - private val expandedMenuBounds: Rect, - private val endPaddingPx: Int, -) : PopupPositionProvider { - override fun calculatePosition( - anchorBounds: IntRect, - windowSize: IntSize, - layoutDirection: LayoutDirection, - popupContentSize: IntSize, - ): IntOffset { - val x = windowSize.width - popupContentSize.width - endPaddingPx - val y = (collapsedFabBounds.bottom - expandedMenuBounds.height).roundToInt() - - return IntOffset( - x = max(0, x), - y = max(0, y), - ) - } -} - -@Composable -private fun HomeContent( - uiState: HomeUiState, - pagerState: PagerState, - maxHeight: Dp, - headerHeight: Dp, - onClickAddPresentation: () -> Unit, - onClickPracticeRecording: () -> Unit, - onClickAnalyzePresentation: (PresentationUiModel) -> Unit, - onClickWriteFeedback: (PresentationUiModel) -> Unit, - onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, -) { - when (uiState) { - HomeUiState.Loading -> Unit - is HomeUiState.Empty -> { - HomeEmptyContent( - maxHeight = maxHeight, - headerHeight = headerHeight, - uiState = uiState, - onClickAddPresentation = onClickAddPresentation, - onClickPracticeRecording = onClickPracticeRecording, - ) - } - - is HomeUiState.SingleContent -> { - HomeSingleContent( - uiState = uiState, - maxHeight = maxHeight, - headerHeight = headerHeight, - onClickPracticeRecording = onClickPracticeRecording, - onClickAnalyzePresentation = onClickAnalyzePresentation, - onClickWriteFeedback = onClickWriteFeedback, - onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(uiState.presentation.id, index) }, - ) - } - - is HomeUiState.MultipleContent -> { - HomeMultipleContent( - uiState = uiState, - pagerState = pagerState, - maxHeight = maxHeight, - headerHeight = headerHeight, - onClickPracticeRecording = onClickPracticeRecording, - onClickAnalyzePresentation = onClickAnalyzePresentation, - onClickWriteFeedback = onClickWriteFeedback, - onClickCardGraphItemIndex = onClickCardGraphItemIndex, - ) - } - } -} - -@Composable -private fun HomeEmptyContent( - maxHeight: Dp, - headerHeight: Dp, - uiState: HomeUiState.Empty, - onClickAddPresentation: () -> Unit, - onClickPracticeRecording: () -> Unit, -) { - HomePageLayout( - maxHeight = maxHeight, - headerHeight = headerHeight, - sheetContent = { EmptyPresentationSheet(onClickPracticeRecording = onClickPracticeRecording) }, - heroContent = { - EmptyPresentationHero( - nickname = uiState.nickname, - onClickAddPresentation = onClickAddPresentation, - ) - }, - ) -} - -@Composable -private fun HomeSingleContent( - uiState: HomeUiState.SingleContent, - maxHeight: Dp, - headerHeight: Dp, - onClickPracticeRecording: () -> Unit, - onClickAnalyzePresentation: (PresentationUiModel) -> Unit, - onClickWriteFeedback: (PresentationUiModel) -> Unit, - onClickCardGraphItemIndex: (index: Int) -> Unit, -) { - HomePageLayout( - maxHeight = maxHeight, - headerHeight = headerHeight, - sheetContent = { - PresentationSheet( - presentation = uiState.presentation, - onClickPracticeRecording = onClickPracticeRecording, - onClickCardGraphItemIndex = onClickCardGraphItemIndex, - ) - }, - heroContent = { - PresentationHero( - presentation = uiState.presentation, - onClickAnalyzePresentation = onClickAnalyzePresentation, - onClickWriteFeedback = onClickWriteFeedback, - ) - }, - ) -} - -@Composable -private fun HomeMultipleContent( - uiState: HomeUiState.MultipleContent, - pagerState: PagerState, - maxHeight: Dp, - headerHeight: Dp, - onClickPracticeRecording: () -> Unit, - onClickAnalyzePresentation: (PresentationUiModel) -> Unit, - onClickWriteFeedback: (PresentationUiModel) -> Unit, - onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, -) { - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxSize(), - overscrollEffect = null, - userScrollEnabled = false, - key = { pageIndex -> uiState.presentations[pageIndex].id }, - ) { pageIndex -> - val presentation = uiState.presentations[pageIndex] - - HomePageLayout( - maxHeight = maxHeight, - headerHeight = headerHeight, - sheetContent = { - PresentationSheet( - presentation = presentation, - onClickPracticeRecording = onClickPracticeRecording, - onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(presentation.id, index) }, - ) - }, - heroContent = { - PresentationHero( - presentation = presentation, - onClickAnalyzePresentation = onClickAnalyzePresentation, - onClickWriteFeedback = onClickWriteFeedback, - ) - }, - ) - } -} - @BasicPreview @Composable private fun HomeScreenEmptyPreview() { val uiState = HomeUiState.Empty(nickname = "프레즐") - PrezelTheme { - HomeScreen( - uiState = uiState, - pagerState = rememberPagerState(0) { uiState.presentationCount() }, - onClickAddPresentation = { }, - onClickPracticeRecording = { }, - onClickAnalyzePresentation = { }, - onClickWriteFeedback = { }, - onClickVoiceRecordingAnalysis = { }, - onClickFileUploadAnalysis = { }, - onClickCardGraphItemIndex = { presentationId, index -> }, - ) - } + HomeScreenPreview(uiState = uiState) } @BasicPreview @@ -473,28 +91,21 @@ private fun HomeScreenSinglePreview() { category = Category.OFFER, title = "날짜 지난 발표제목", date = LocalDate(2026, 4, 3), - dDay = "+1", + dDay = "D+1", practiceRecords = PracticeRecordsUiModel( practicedDates = listOf(LocalDate(2026, 4, 1), LocalDate(2026, 4, 3)), startDate = LocalDate(2026, 3, 30), endDate = LocalDate(2026, 4, 3), ), - growthGraphData = GrowthGraphData(items = emptyList(), selectedItemIndex = 0), + growthGraphData = GrowthGraphData( + items = List(3) { index -> + GrowthGraphItemUiModel(attempt = index + 1, accuracyScore = 15.0 * index, scriptMatchRate = 10.0 * index) + }, + selectedItemIndex = null, + ), ), ) - PrezelTheme { - HomeScreen( - uiState = uiState, - pagerState = rememberPagerState(0) { uiState.presentationCount() }, - onClickAddPresentation = { }, - onClickPracticeRecording = { }, - onClickAnalyzePresentation = { }, - onClickWriteFeedback = { }, - onClickVoiceRecordingAnalysis = { }, - onClickFileUploadAnalysis = { }, - onClickCardGraphItemIndex = { presentationId, index -> }, - ) - } + HomeScreenPreview(uiState = uiState) } @BasicPreview @@ -516,17 +127,26 @@ private fun HomeScreenMultiplePreview() { ) }.toPersistentList(), ) + HomeScreenPreview(uiState = uiState) +} + +@Composable +private fun HomeScreenPreview(uiState: HomeUiState) { PrezelTheme { - HomeScreen( - uiState = uiState, - pagerState = rememberPagerState(0) { uiState.presentationCount() }, - onClickAddPresentation = { }, - onClickPracticeRecording = { }, - onClickAnalyzePresentation = { }, - onClickWriteFeedback = { }, - onClickVoiceRecordingAnalysis = { }, - onClickFileUploadAnalysis = { }, - onClickCardGraphItemIndex = { presentationId, index -> }, - ) + CompositionLocalProvider( + LocalAppDimmerState provides rememberAppDimmerState(), + ) { + HomeScreenContent( + uiState = uiState, + pagerState = rememberPagerState(0) { uiState.presentationCount() }, + onClickAddPresentation = {}, + onClickPracticeRecording = {}, + onClickAnalyzePresentation = {}, + onClickWriteFeedback = {}, + onClickVoiceRecordingAnalysis = {}, + onClickFileUploadAnalysis = {}, + onClickCardGraphItemIndex = { _, _ -> }, + ) + } } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt new file mode 100644 index 00000000..1c80b5c3 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeAnalysisFabOverlay.kt @@ -0,0 +1,228 @@ +package com.team.prezel.feature.home.impl.main.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +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.floating.PrezelFloatingMenu +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.state.LocalAppDimmerState +import com.team.prezel.feature.home.impl.R +import kotlin.math.max +import kotlin.math.roundToInt + +@Composable +internal fun HomeAnalysisFabOverlay( + onClickVoiceRecordingAnalysis: () -> Unit, + onClickFileUploadAnalysis: () -> Unit, + modifier: Modifier = Modifier, +) { + val appDimmerState = LocalAppDimmerState.current + val density = LocalDensity.current + val fabEndPaddingPx = with(density) { PrezelTheme.spacing.V24.roundToPx() } + var isFabExpanded by remember { mutableStateOf(false) } + var collapsedFabBounds by remember { mutableStateOf(null) } + var expandedMenuBounds by remember { mutableStateOf(null) } + + LaunchedEffect(isFabExpanded) { + if (isFabExpanded) { + appDimmerState.show { isFabExpanded = false } + } else { + appDimmerState.hide() + } + } + + DisposableEffect(appDimmerState) { + onDispose { appDimmerState.hide() } + } + + Box(modifier = modifier.fillMaxSize()) { + HomeAnalysisFabAnchors( + onCollapsedFabPositioned = { coordinates -> collapsedFabBounds = coordinates.boundsInWindow() }, + onExpandedMenuPositioned = { coordinates -> expandedMenuBounds = coordinates.boundsInWindow() }, + ) + + HomeAnalysisFabPopup( + collapsedFabBounds = collapsedFabBounds, + expandedMenuBounds = expandedMenuBounds, + fabEndPaddingPx = fabEndPaddingPx, + density = density, + isFabExpanded = isFabExpanded, + onChangeExpanded = { isFabExpanded = it }, + onClickVoiceRecordingAnalysis = onClickVoiceRecordingAnalysis, + onClickFileUploadAnalysis = onClickFileUploadAnalysis, + ) + } +} + +@Composable +private fun BoxScope.HomeAnalysisFabAnchors( + onCollapsedFabPositioned: (LayoutCoordinates) -> Unit, + onExpandedMenuPositioned: (LayoutCoordinates) -> Unit, +) { + HomeAnalysisFabMeasurementAnchor( + isExpanded = false, + modifier = Modifier.align(Alignment.BottomEnd), + onFabPositioned = onCollapsedFabPositioned, + ) + HomeAnalysisFabMeasurementAnchor( + isExpanded = true, + modifier = Modifier.align(Alignment.BottomEnd), + onFabPositioned = onExpandedMenuPositioned, + ) +} + +@Composable +private fun HomeAnalysisFabPopup( + collapsedFabBounds: Rect?, + expandedMenuBounds: Rect?, + fabEndPaddingPx: Int, + density: androidx.compose.ui.unit.Density, + isFabExpanded: Boolean, + onChangeExpanded: (Boolean) -> Unit, + onClickVoiceRecordingAnalysis: () -> Unit, + onClickFileUploadAnalysis: () -> Unit, +) { + val expandedBounds = expandedMenuBounds ?: return + val collapsedBounds = collapsedFabBounds ?: return + val expandedMenuWidth = with(density) { expandedBounds.width.toDp() } + val expandedMenuHeight = with(density) { expandedBounds.height.toDp() } + + Popup( + popupPositionProvider = remember(collapsedBounds, expandedBounds, fabEndPaddingPx) { + HomeFabPopupPositionProvider( + collapsedFabBounds = collapsedBounds, + expandedMenuBounds = expandedBounds, + endPaddingPx = fabEndPaddingPx, + ) + }, + ) { + Box( + modifier = Modifier.requiredSize( + width = expandedMenuWidth, + height = expandedMenuHeight, + ), + contentAlignment = Alignment.BottomEnd, + ) { + HomeAnalysisFloatingMenu( + isExpanded = isFabExpanded, + onChangeExpanded = onChangeExpanded, + onClickVoiceRecording = { + onChangeExpanded(false) + onClickVoiceRecordingAnalysis() + }, + onClickFileUpload = { + onChangeExpanded(false) + onClickFileUploadAnalysis() + }, + ) + } + } +} + +@Composable +private fun HomeAnalysisFabMeasurementAnchor( + isExpanded: Boolean, + modifier: Modifier = Modifier, + onFabPositioned: (LayoutCoordinates) -> Unit, +) { + HomeAnalysisFloatingMenu( + isExpanded = isExpanded, + onChangeExpanded = {}, + onClickVoiceRecording = {}, + onClickFileUpload = {}, + enabled = false, + modifier = modifier + .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24) + .graphicsLayer { alpha = 0f } + .clearAndSetSemantics { }, + onFabPositioned = onFabPositioned, + ) +} + +@Composable +private fun HomeAnalysisFloatingMenu( + isExpanded: Boolean, + onChangeExpanded: (Boolean) -> Unit, + onClickVoiceRecording: () -> Unit, + onClickFileUpload: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onFabPositioned: ((LayoutCoordinates) -> Unit)? = null, +) { + PrezelFloatingMenu( + isExpanded = isExpanded, + onChangeExpanded = onChangeExpanded, + iconResId = PrezelIcons.Plus, + openIconResId = PrezelIcons.Cancel, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.PRIMARY, + enabled = enabled, + modifier = modifier.then( + if (onFabPositioned == null) { + Modifier + } else { + Modifier.onGloballyPositioned(onFabPositioned) + }, + ), + ) { + MenuItem( + label = stringResource(R.string.feature_home_impl_analysis_voice_recording), + iconResId = PrezelIcons.Mic, + onClick = onClickVoiceRecording, + ) + MenuItem( + label = stringResource(R.string.feature_home_impl_analysis_file_upload), + iconResId = PrezelIcons.Folder, + onClick = onClickFileUpload, + ) + } +} + +private class HomeFabPopupPositionProvider( + private val collapsedFabBounds: Rect, + private val expandedMenuBounds: Rect, + private val endPaddingPx: Int, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + val x = windowSize.width - popupContentSize.width - endPaddingPx + val y = (collapsedFabBounds.bottom - expandedMenuBounds.height).roundToInt() + + return IntOffset( + x = max(0, x), + y = max(0, y), + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt new file mode 100644 index 00000000..993c618e --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt @@ -0,0 +1,208 @@ +package com.team.prezel.feature.home.impl.main.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.team.prezel.core.ui.util.onHeightChanged +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.HomeUiState +import com.team.prezel.feature.home.impl.main.model.PresentationUiModel +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HomeScreenContent( + uiState: HomeUiState, + pagerState: PagerState, + onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickVoiceRecordingAnalysis: () -> Unit, + onClickFileUploadAnalysis: () -> Unit, + onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val maxScreenHeight = maxHeight + var headerHeight by remember { mutableStateOf(0.dp) } + + Box(modifier = Modifier.fillMaxSize()) { + HomeContent( + uiState = uiState, + pagerState = pagerState, + maxHeight = maxScreenHeight, + headerHeight = headerHeight, + onClickAddPresentation = onClickAddPresentation, + onClickPracticeRecording = onClickPracticeRecording, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, + ) + + HomeHeadSection( + uiState = uiState, + pagerState = pagerState, + onClickTab = { pageIndex -> scope.launch { pagerState.scrollToPage(pageIndex) } }, + modifier = Modifier.onHeightChanged { newHeight -> headerHeight = newHeight }, + ) + + HomeAnalysisFabOverlay( + onClickVoiceRecordingAnalysis = onClickVoiceRecordingAnalysis, + onClickFileUploadAnalysis = onClickFileUploadAnalysis, + ) + } + } +} + +@Composable +private fun HomeContent( + uiState: HomeUiState, + pagerState: PagerState, + maxHeight: Dp, + headerHeight: Dp, + onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, +) { + when (uiState) { + HomeUiState.Loading -> Unit + is HomeUiState.Empty -> { + HomeEmptyContent( + maxHeight = maxHeight, + headerHeight = headerHeight, + nickname = uiState.nickname, + onClickAddPresentation = onClickAddPresentation, + onClickPracticeRecording = onClickPracticeRecording, + ) + } + + is HomeUiState.SingleContent -> { + HomePresentationContent( + presentation = uiState.presentation, + maxHeight = maxHeight, + headerHeight = headerHeight, + onClickPracticeRecording = onClickPracticeRecording, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(uiState.presentation.id, index) }, + ) + } + + is HomeUiState.MultipleContent -> { + HomeMultipleContent( + uiState = uiState, + pagerState = pagerState, + maxHeight = maxHeight, + headerHeight = headerHeight, + onClickPracticeRecording = onClickPracticeRecording, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, + ) + } + } +} + +@Composable +private fun HomeEmptyContent( + maxHeight: Dp, + headerHeight: Dp, + nickname: String, + onClickAddPresentation: () -> Unit, + onClickPracticeRecording: () -> Unit, +) { + HomePageLayout( + maxHeight = maxHeight, + headerHeight = headerHeight, + sheetContent = { EmptyPresentationSheet(onClickPracticeRecording = onClickPracticeRecording) }, + heroContent = { + EmptyPresentationHero( + nickname = nickname, + onClickAddPresentation = onClickAddPresentation, + ) + }, + ) +} + +@Composable +private fun HomePresentationContent( + presentation: PresentationUiModel, + maxHeight: Dp, + headerHeight: Dp, + onClickPracticeRecording: () -> Unit, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickCardGraphItemIndex: (index: Int) -> Unit, +) { + HomePageLayout( + maxHeight = maxHeight, + headerHeight = headerHeight, + sheetContent = { + PresentationSheet( + presentation = presentation, + onClickPracticeRecording = onClickPracticeRecording, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, + ) + }, + heroContent = { + PresentationHero( + presentation = presentation, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + ) + }, + ) +} + +@Composable +private fun HomeMultipleContent( + uiState: HomeUiState.MultipleContent, + pagerState: PagerState, + maxHeight: Dp, + headerHeight: Dp, + onClickPracticeRecording: () -> Unit, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, + onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, +) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + overscrollEffect = null, + userScrollEnabled = false, + key = { pageIndex -> uiState.presentations[pageIndex].id }, + ) { pageIndex -> + val presentation = uiState.presentations[pageIndex] + + HomePresentationContent( + presentation = presentation, + maxHeight = maxHeight, + headerHeight = headerHeight, + onClickPracticeRecording = onClickPracticeRecording, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(presentation.id, index) }, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index 654d55af..b075b3c5 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -66,7 +66,7 @@ private fun PresentationContentPreview() { category = Category.OFFER, title = "설득하는 발표", date = LocalDate(2026, 10, 1), - dDay = "-3", + dDay = "D-3", practiceRecords = PracticeRecordsUiModel( practicedDates = List(5) { LocalDate(2026, 9, 26 + it) }, startDate = LocalDate(2026, 9, 26), diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt index f8088c50..44fcebbd 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt @@ -24,7 +24,6 @@ internal data class GrowthGraphData( val items: List, val selectedItemIndex: Int? = null, ) { - val selectedItem: GrowthGraphItemUiModel? = selectedItemIndex?.let { index -> items.getOrNull(index) } val graphItems: ImmutableList = items.map(GrowthGraphItemUiModel::graphItem).toImmutableList() companion object { From 89f4db6d6847ba12379d63189c55ad445666bee4 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 19:44:39 +0900 Subject: [PATCH 07/12] =?UTF-8?q?refactor:=20=EC=97=B0=EC=8A=B5=20?= =?UTF-8?q?=EB=85=B9=EC=9D=8C=20=EB=B6=84=EC=84=9D=20API=20=EB=B0=8F=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=EB=A1=9C=EC=A7=81=EC=97=90=20presentation?= =?UTF-8?q?Id=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 네트워크 및 데이터 레이어 내 발표 ID 파라미터 추가** * `PracticeService`의 연습 녹음 분석 API(`analyzePracticeRecording`)에 `presentationId` 쿼리 파라미터를 추가했습니다. * `PracticeRemoteDataSource` 및 `PracticeRepository` 인터페이스와 관련 구현체에 `presentationId` 전달 로직을 반영했습니다. * **refactor: 네비게이션 키 구조 변경 및 파라미터 전달 로직 개선** * `PracticeNavKey`를 `data object`에서 `presentationId`를 포함하는 `data class`로 변경했습니다. * `PracticeAnalysisNavKey`에 분석 대상 발표를 식별하기 위한 `presentationId` 필드를 추가했습니다. * `HomeEntryBuilder` 및 `PracticeEntryBuilder`에서 변경된 네비게이션 키를 통해 ID가 전달되도록 수정했습니다. * **refactor: UI 컴포넌트 및 네비게이션 의존성 정리** * `HomeScreen`에서 `LocalNavigator` 직접 참조를 제거하고, `navigateToPracticeRecording` 콜백을 통해 네비게이션을 처리하도록 개선했습니다. * `HomeScreenContent` 및 하위 컴포넌트에서 연습 녹음 시작 시 해당 발표의 ID를 전달하도록 변경했습니다. * `EmptyPresentationSheet`의 불필요한 클릭 리스너 전달 로직을 정리했습니다. * **refactor: 도메인 및 ViewModel 내 발표 ID 활용** * `AnalyzePracticeRecordingUseCase`가 `presentationId`를 요구하도록 수정했습니다. * `PracticeAnalysisViewModel` 및 Assisted Factory에 `presentationId`를 추가하여 분석 요청 시 사용하도록 구현했습니다. --- .../data/repository/PracticeRepositoryImpl.kt | 2 + .../repository/practice/PracticeRepository.kt | 1 + .../AnalyzePracticeRecordingUseCase.kt | 2 + .../FetchPresentationDetailUseCase.kt | 27 ++++++-- .../PresentationDetailWithPracticeRecords.kt | 6 ++ .../datasource/PracticeRemoteDataSource.kt | 1 + .../PracticeRemoteDataSourceImpl.kt | 2 + .../core/network/service/PracticeService.kt | 1 + .../feature/home/impl/main/HomeScreen.kt | 6 +- .../impl/main/component/HomeScreenContent.kt | 35 +++++++--- .../component/body/EmptyPresentationSheet.kt | 9 +-- .../home/impl/navigation/HomeEntryBuilder.kt | 4 ++ .../feature/practice/api/PracticeNavKey.kt | 4 +- .../analysis/PracticeAnalysisViewModel.kt | 7 +- .../impl/navigation/PracticeAnalysisNavKey.kt | 1 + .../impl/navigation/PracticeEntryBuilder.kt | 4 +- .../report/impl/AnalysisReportViewModel.kt | 4 +- .../impl/component/ReportBodyContent.kt | 4 +- .../component/body/PracticeHistorySection.kt | 68 ++++++------------- .../impl/contract/AnalysisReportUiState.kt | 2 + .../contract/AnalysisReportUiStateMapper.kt | 42 ++++++------ .../report/impl/model/PracticeUiModel.kt | 40 +++++++++-- .../impl/preview/ReportPreviewUiState.kt | 11 +++ 23 files changed, 179 insertions(+), 104 deletions(-) create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationDetailWithPracticeRecords.kt 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 index 4638be55..7ea044f0 100644 --- 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 @@ -19,11 +19,13 @@ internal class PracticeRepositoryImpl @Inject constructor( }.mapDomainFailure() override suspend fun analyzePracticeRecording( + presentationId: Long, recordingFilePath: String, referenceText: String, ): Result = runCatching { practiceRemoteDataSource.analyzePracticeRecording( + presentationId = presentationId, recordingFilePath = recordingFilePath, referenceText = referenceText, ) 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 index 340ccf6a..ff9f7b4c 100644 --- 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 @@ -7,6 +7,7 @@ interface PracticeRepository { suspend fun fetchPracticeScript(): Result suspend fun analyzePracticeRecording( + presentationId: Long, 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 index 021eda97..5423fb29 100644 --- 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 @@ -8,10 +8,12 @@ class AnalyzePracticeRecordingUseCase @Inject constructor( private val practiceRepository: PracticeRepository, ) { suspend operator fun invoke( + presentationId: Long, recordingFilePath: String, referenceText: String, ): Result = practiceRepository.analyzePracticeRecording( + presentationId = presentationId, recordingFilePath = recordingFilePath, referenceText = referenceText, ) diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPresentationDetailUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPresentationDetailUseCase.kt index 4fd9744e..3bfe7be6 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPresentationDetailUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchPresentationDetailUseCase.kt @@ -1,7 +1,9 @@ package com.team.prezel.core.domain.usecase.presentation import com.team.prezel.core.domain.repository.presentation.PresentationRepository -import com.team.prezel.core.model.presentation.PresentationAnalysisSummary +import com.team.prezel.core.model.presentation.PresentationDetailWithPracticeRecords +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import javax.inject.Inject class FetchPresentationDetailUseCase @Inject constructor( @@ -10,9 +12,24 @@ class FetchPresentationDetailUseCase @Inject constructor( suspend operator fun invoke( presentationId: Long, isPast: Boolean = false, - ): Result { - if (isPast) return presentationRepository.getPastPresentationDetail(presentationId = presentationId) + ): Result = + runCatching { + coroutineScope { + val presentationDetailDeferred = async { + if (isPast) { + presentationRepository.getPastPresentationDetail(presentationId = presentationId).getOrThrow() + } else { + presentationRepository.getUpcomingPresentationDetail(presentationId = presentationId).getOrThrow() + } + } + val practiceRecordsDeferred = async { + presentationRepository.getPracticeRecords(presentationId = presentationId).getOrThrow() + } - return presentationRepository.getUpcomingPresentationDetail(presentationId = presentationId) - } + PresentationDetailWithPracticeRecords( + analysisSummary = presentationDetailDeferred.await(), + practiceRecords = practiceRecordsDeferred.await(), + ) + } + } } diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationDetailWithPracticeRecords.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationDetailWithPracticeRecords.kt new file mode 100644 index 00000000..088717c2 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/PresentationDetailWithPracticeRecords.kt @@ -0,0 +1,6 @@ +package com.team.prezel.core.model.presentation + +data class PresentationDetailWithPracticeRecords( + val analysisSummary: PresentationAnalysisSummary, + val practiceRecords: PracticeRecords, +) 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 index 1619412d..d3ca38ce 100644 --- 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 @@ -7,6 +7,7 @@ interface PracticeRemoteDataSource { suspend fun getPracticeSentence(): PracticeSentenceResponse suspend fun analyzePracticeRecording( + presentationId: Long, 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 index f2680284..0f341e05 100644 --- 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 @@ -19,6 +19,7 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( override suspend fun getPracticeSentence(): PracticeSentenceResponse = practiceService.getPracticeSentence().requireData() override suspend fun analyzePracticeRecording( + presentationId: Long, recordingFilePath: String, referenceText: String, ): AnalyzePracticeRecordingResponse { @@ -40,6 +41,7 @@ internal class PracticeRemoteDataSourceImpl @Inject constructor( return practiceService .analyzePracticeRecording( + presentationId = presentationId, referenceText = referenceText, audio = multipart, ).requireData() 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 index 656530aa..6acfae75 100644 --- 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 @@ -16,6 +16,7 @@ interface PracticeService { @POST("recording/practice/analyze") suspend fun analyzePracticeRecording( + @Query("presentationId") presentationId: Long, @Query("referenceText") referenceText: String, @Body audio: MultiPartFormDataContent, ): BaseResponse diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index f6a7a3aa..c32df79d 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -13,7 +13,6 @@ 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.LocalAppDimmerState import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.core.ui.state.rememberAppDimmerState @@ -27,12 +26,12 @@ import com.team.prezel.feature.home.impl.main.model.GrowthGraphItemUiModel import com.team.prezel.feature.home.impl.main.model.HomeUiMessage import com.team.prezel.feature.home.impl.main.model.PracticeRecordsUiModel 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.datetime.LocalDate @Composable internal fun HomeScreen( + navigateToPracticeRecording: (presentationId: Long) -> Unit, navigateToFileUploadAnalysis: () -> Unit, navigateToVoiceRecordingAnalysis: () -> Unit, modifier: Modifier = Modifier, @@ -42,7 +41,6 @@ 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) @@ -63,7 +61,7 @@ internal fun HomeScreen( uiState = uiState, pagerState = pagerState, onClickAddPresentation = { }, - onClickPracticeRecording = { navigator.navigate(PracticeNavKey) }, + onClickPracticeRecording = { presentationId -> navigateToPracticeRecording(presentationId) }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, onClickVoiceRecordingAnalysis = navigateToVoiceRecordingAnalysis, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt index 993c618e..48bf7b5a 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt @@ -2,7 +2,10 @@ package com.team.prezel.feature.home.impl.main.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.ExperimentalMaterial3Api @@ -13,10 +16,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelButton +import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.util.onHeightChanged -import com.team.prezel.feature.home.impl.main.component.body.EmptyPresentationSheet +import com.team.prezel.feature.home.impl.R +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.body.PresentationSheet import com.team.prezel.feature.home.impl.main.component.head.HomeHeadSection import com.team.prezel.feature.home.impl.main.component.title.EmptyPresentationHero @@ -31,7 +39,7 @@ internal fun HomeScreenContent( uiState: HomeUiState, pagerState: PagerState, onClickAddPresentation: () -> Unit, - onClickPracticeRecording: () -> Unit, + onClickPracticeRecording: (presentationId: Long) -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, onClickVoiceRecordingAnalysis: () -> Unit, @@ -80,7 +88,7 @@ private fun HomeContent( maxHeight: Dp, headerHeight: Dp, onClickAddPresentation: () -> Unit, - onClickPracticeRecording: () -> Unit, + onClickPracticeRecording: (presentationId: Long) -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, @@ -93,7 +101,6 @@ private fun HomeContent( headerHeight = headerHeight, nickname = uiState.nickname, onClickAddPresentation = onClickAddPresentation, - onClickPracticeRecording = onClickPracticeRecording, ) } @@ -102,7 +109,7 @@ private fun HomeContent( presentation = uiState.presentation, maxHeight = maxHeight, headerHeight = headerHeight, - onClickPracticeRecording = onClickPracticeRecording, + onClickPracticeRecording = { onClickPracticeRecording(uiState.presentation.id) }, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(uiState.presentation.id, index) }, @@ -130,12 +137,22 @@ private fun HomeEmptyContent( headerHeight: Dp, nickname: String, onClickAddPresentation: () -> Unit, - onClickPracticeRecording: () -> Unit, ) { HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { EmptyPresentationSheet(onClickPracticeRecording = onClickPracticeRecording) }, + sheetContent = { + HomeBottomSheetContent( + 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 = {}, + ) + } + }, heroContent = { EmptyPresentationHero( nickname = nickname, @@ -181,7 +198,7 @@ private fun HomeMultipleContent( pagerState: PagerState, maxHeight: Dp, headerHeight: Dp, - onClickPracticeRecording: () -> Unit, + onClickPracticeRecording: (presentationId: Long) -> Unit, onClickAnalyzePresentation: (PresentationUiModel) -> Unit, onClickWriteFeedback: (PresentationUiModel) -> Unit, onClickCardGraphItemIndex: (presentationId: Long, index: Int) -> Unit, @@ -199,7 +216,7 @@ private fun HomeMultipleContent( presentation = presentation, maxHeight = maxHeight, headerHeight = headerHeight, - onClickPracticeRecording = onClickPracticeRecording, + onClickPracticeRecording = { onClickPracticeRecording(presentation.id) }, onClickAnalyzePresentation = onClickAnalyzePresentation, onClickWriteFeedback = onClickWriteFeedback, onClickCardGraphItemIndex = { index -> onClickCardGraphItemIndex(presentation.id, index) }, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt index 0ee5c218..270dd205 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt @@ -15,10 +15,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.home.impl.R @Composable -internal fun EmptyPresentationSheet( - onClickPracticeRecording: () -> Unit, - modifier: Modifier = Modifier, -) { +internal fun EmptyPresentationSheet(modifier: Modifier = Modifier) { HomeBottomSheetContent( modifier = modifier, contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), @@ -27,7 +24,7 @@ internal fun EmptyPresentationSheet( Spacer(modifier = Modifier.height(12.dp)) PrezelButton( text = stringResource(R.string.feature_home_impl_practice_recording_action), - onClick = onClickPracticeRecording, + onClick = {}, ) } } @@ -41,7 +38,7 @@ private fun EmptyPresentationContentPreview() { .height(100.dp) .padding(top = 16.dp), ) { - EmptyPresentationSheet(onClickPracticeRecording = {}) + EmptyPresentationSheet() } } } 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 89450139..93f6d055 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 @@ -6,6 +6,7 @@ import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.analysis.api.AnalysisNavKey import com.team.prezel.feature.home.api.HomeNavKey import com.team.prezel.feature.home.impl.main.HomeScreen +import com.team.prezel.feature.practice.api.PracticeNavKey import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,6 +18,9 @@ internal fun EntryProviderScope.featureHomeEntryBuilder() { val navigator = LocalNavigator.current HomeScreen( + navigateToPracticeRecording = { presentationId -> + navigator.navigate(PracticeNavKey(presentationId = presentationId)) + }, navigateToFileUploadAnalysis = { navigator.navigate(AnalysisNavKey.Create) }, 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 index e9de678b..f0bfeae2 100644 --- 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 @@ -4,4 +4,6 @@ import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable @Serializable -data object PracticeNavKey : NavKey +data class PracticeNavKey( + val presentationId: Long, +) : NavKey diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeAnalysisViewModel.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeAnalysisViewModel.kt index db126c41..ea1c2211 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeAnalysisViewModel.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/analysis/PracticeAnalysisViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = PracticeAnalysisViewModel.Factory::class) internal class PracticeAnalysisViewModel @AssistedInject constructor( + @Assisted("presentationId") private val presentationId: Long, @Assisted("recordingFilePath") private val recordingFilePath: String, @Assisted("referenceText") private val referenceText: String, private val analyzePracticeRecordingUseCase: AnalyzePracticeRecordingUseCase, @@ -26,8 +27,9 @@ internal class PracticeAnalysisViewModel @AssistedInject constructor( @AssistedFactory interface Factory { fun create( - @Assisted("recordingFilePath")recordingFilePath: String, - @Assisted("referenceText")referenceText: String, + @Assisted("presentationId") presentationId: Long, + @Assisted("recordingFilePath") recordingFilePath: String, + @Assisted("referenceText") referenceText: String, ): PracticeAnalysisViewModel } @@ -42,6 +44,7 @@ internal class PracticeAnalysisViewModel @AssistedInject constructor( updateState { PracticeAnalysisUiState.Loading } analyzePracticeRecordingUseCase( + presentationId = presentationId, recordingFilePath = recordingFilePath, referenceText = referenceText, ).onSuccess { result -> diff --git a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeAnalysisNavKey.kt b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeAnalysisNavKey.kt index 82fecafd..fbdf05f0 100644 --- a/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeAnalysisNavKey.kt +++ b/Prezel/feature/practice/impl/src/main/java/com/team/prezel/feature/practice/impl/navigation/PracticeAnalysisNavKey.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.Serializable @Serializable internal data class PracticeAnalysisNavKey( + val presentationId: Long, val recordingFilePath: String, val referenceText: String, ) : NavKey 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 index a9d84388..37742951 100644 --- 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 @@ -16,7 +16,7 @@ import dagger.hilt.android.components.ActivityRetainedComponent import dagger.multibindings.IntoSet internal fun EntryProviderScope.featurePracticeEntryBuilder() { - entry { + entry { key -> val navigator = LocalNavigator.current PracticeRecordingScreen( @@ -24,6 +24,7 @@ internal fun EntryProviderScope.featurePracticeEntryBuilder() { navigateToAnalysis = { recordingFilePath, referenceText -> navigator.navigate( PracticeAnalysisNavKey( + presentationId = key.presentationId, recordingFilePath = recordingFilePath, referenceText = referenceText, ), @@ -40,6 +41,7 @@ internal fun EntryProviderScope.featurePracticeEntryBuilder() { onComplete = { navigator.replaceRoot(HomeNavKey) }, viewModel = hiltViewModel { factory -> factory.create( + presentationId = key.presentationId, recordingFilePath = key.recordingFilePath, referenceText = key.referenceText, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt index 956d1932..9e5ab1ae 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/AnalysisReportViewModel.kt @@ -54,8 +54,8 @@ internal class AnalysisReportViewModel @AssistedInject constructor( viewModelScope.launch { fetchPresentationDetailUseCase(presentationId = presentationId, isPast = isPast) .onSuccess { result -> - this@AnalysisReportViewModel.presentationId = result.presentationId - analysisResultId = result.analysisResultId + this@AnalysisReportViewModel.presentationId = result.analysisSummary.presentationId + analysisResultId = result.analysisSummary.analysisResultId updateState { result.toAnalysisReportUiState(isPast = isPast) } }.onFailure { sendEffect(AnalysisReportUiEffect.ShowMessage(AnalysisReportUiMessage.FETCH_REPORT_FAILED)) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportBodyContent.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportBodyContent.kt index 76e58aa0..5e403e42 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportBodyContent.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/ReportBodyContent.kt @@ -24,6 +24,7 @@ import com.team.prezel.feature.report.impl.R import com.team.prezel.feature.report.impl.component.body.AccuracySection import com.team.prezel.feature.report.impl.component.body.ExpectedQuestionsSection import com.team.prezel.feature.report.impl.component.body.GrowthGraphSection +import com.team.prezel.feature.report.impl.component.body.PracticeHistorySection import com.team.prezel.feature.report.impl.component.body.ScriptAnalysisSection import com.team.prezel.feature.report.impl.component.body.SelfFeedbackSection import com.team.prezel.feature.report.impl.component.body.SummarySection @@ -45,8 +46,7 @@ internal fun ReportBodyContent( selfFeedback = uiState.selfFeedback, onFeedBackWriteClick = onFeedBackWriteClick, ) - // todo: API 배포 이후 수정 예정 - // PracticeHistorySection() + PracticeHistorySection(practiceRecords = uiState.practiceRecords) } SummarySection(summary = uiState.summaryFeedback) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/PracticeHistorySection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/PracticeHistorySection.kt index 71dbad1a..9d1d11b1 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/PracticeHistorySection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/component/body/PracticeHistorySection.kt @@ -6,50 +6,29 @@ import androidx.compose.ui.res.stringResource import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.component.PracticeCard -import com.team.prezel.core.ui.component.PracticeCardItem import com.team.prezel.feature.report.impl.R import com.team.prezel.feature.report.impl.component.common.ReportSection -import com.team.prezel.feature.report.impl.model.PracticeUiModel -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList +import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate import kotlinx.datetime.plus @Composable -internal fun PracticeHistorySection( - presentationDate: LocalDate, - practices: ImmutableList, -) { +internal fun PracticeHistorySection(practiceRecords: PracticeRecordsUiModel) { ReportSection( title = { Text( text = stringResource(R.string.feature_report_impl_section_practice_history), - style = PrezelTheme.typography.body2Bold, + style = PrezelTheme.typography.title2Bold, color = PrezelTheme.colors.textLarge, ) }, ) { - if (practices.isEmpty()) { - Text( - text = stringResource(R.string.feature_report_impl_empty_practice_history), - style = PrezelTheme.typography.body2Regular, - color = PrezelTheme.colors.textMedium, - ) - } else { - PracticeCard( - dDay = presentationDate, - items = practices - .map { item -> - PracticeCardItem( - date = item.date, - isPracticed = item.isPracticed, - ) - }.toImmutableList(), - showActionButton = false, - ) - } + PracticeCard( + dDay = practiceRecords.endDate, + items = practiceRecords.practices, + showActionButton = false, + ) } } @@ -57,26 +36,19 @@ internal fun PracticeHistorySection( @Composable private fun PracticeHistorySectionPreview() { val base = LocalDate(2026, 5, 14) - PrezelTheme { - PracticeHistorySection( - presentationDate = base.plus(8, DateTimeUnit.DAY), - practices = List(8) { - PracticeUiModel( - date = base.plus(it, DateTimeUnit.DAY), - isPracticed = it % 2 == 0, - ) - }.toImmutableList(), - ) - } -} -@BasicPreview -@Composable -private fun EmptyPracticeHistorySectionPreview() { + val practiceRecords = PracticeRecordsUiModel( + startDate = base, + endDate = base.plus(8, DateTimeUnit.DAY), + practicedDates = listOf( + LocalDate(2026, 5, 14), + LocalDate(2026, 5, 16), + LocalDate(2026, 5, 17), + LocalDate(2026, 5, 20), + ), + ) + PrezelTheme { - PracticeHistorySection( - presentationDate = LocalDate(2026, 5, 14), - practices = persistentListOf(), - ) + PracticeHistorySection(practiceRecords = practiceRecords) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiState.kt index 7e2afcba..ffbd03b5 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiState.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiState.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.base.UiState import com.team.prezel.feature.report.impl.model.AnalysisReportDialog import com.team.prezel.feature.report.impl.model.GrowthGraphData +import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel import com.team.prezel.feature.report.impl.model.PresentationInfoUiModel import com.team.prezel.feature.report.impl.model.QuestionUiModel import com.team.prezel.feature.report.impl.model.ScriptAnalysisGraphData @@ -25,6 +26,7 @@ internal sealed interface AnalysisReportUiState : UiState { val expectedQuestions: ImmutableList, val selfFeedback: String?, val isPast: Boolean, + val practiceRecords: PracticeRecordsUiModel, val reportDialog: AnalysisReportDialog? = null, ) : AnalysisReportUiState { val isScriptWritten: Boolean = accuracyScore != null && scriptMatchRate != null diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiStateMapper.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiStateMapper.kt index bfc360a2..f1d909a5 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiStateMapper.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/contract/AnalysisReportUiStateMapper.kt @@ -1,10 +1,11 @@ package com.team.prezel.feature.report.impl.contract import com.team.prezel.core.model.presentation.ExpectedQuestion -import com.team.prezel.core.model.presentation.PresentationAnalysisSummary +import com.team.prezel.core.model.presentation.PresentationDetailWithPracticeRecords import com.team.prezel.core.model.presentation.PresentationGrowthPoint import com.team.prezel.feature.report.impl.model.GrowthGraphData import com.team.prezel.feature.report.impl.model.GrowthGraphItemUiModel +import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel.Companion.toUiModel import com.team.prezel.feature.report.impl.model.PresentationInfoUiModel import com.team.prezel.feature.report.impl.model.QuestionUiModel import com.team.prezel.feature.report.impl.model.ScriptAnalysisGraphData @@ -12,32 +13,33 @@ import com.team.prezel.feature.report.impl.model.SpeedGraphData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -internal fun PresentationAnalysisSummary.toAnalysisReportUiState(isPast: Boolean): AnalysisReportUiState = +internal fun PresentationDetailWithPracticeRecords.toAnalysisReportUiState(isPast: Boolean): AnalysisReportUiState = AnalysisReportUiState.Content( presentationInfo = PresentationInfoUiModel( - category = category, - title = title, - purpose = purpose, - style = style, - audience = audience, - analyzedAt = analyzedAt, - durationSeconds = durationSeconds, + category = analysisSummary.category, + title = analysisSummary.title, + purpose = analysisSummary.purpose, + style = analysisSummary.style, + audience = analysisSummary.audience, + analyzedAt = analysisSummary.analyzedAt, + durationSeconds = analysisSummary.durationSeconds, ), - summaryFeedback = summaryFeedback, - accuracyScore = accuracyScore, - scriptMatchRate = scriptMatchRate, + summaryFeedback = analysisSummary.summaryFeedback, + accuracyScore = analysisSummary.accuracyScore, + scriptMatchRate = analysisSummary.scriptMatchRate, speedGraphData = SpeedGraphData( - spm = spm, - result = speedEvaluation, + spm = analysisSummary.spm, + result = analysisSummary.speedEvaluation, ), - growthGraphData = growth.toGrowthGraphData(), + growthGraphData = analysisSummary.growth.toGrowthGraphData(), scriptAnalysisGraphData = ScriptAnalysisGraphData( - spellingCount = spellErrorCount, - grammarCount = grammarErrorCount, - totalErrorCount = totalErrorCount, + spellingCount = analysisSummary.spellErrorCount, + grammarCount = analysisSummary.grammarErrorCount, + totalErrorCount = analysisSummary.totalErrorCount, ), - expectedQuestions = expectedQuestions.toQuestionUiModels(), - selfFeedback = selfFeedback, + expectedQuestions = analysisSummary.expectedQuestions.toQuestionUiModels(), + selfFeedback = analysisSummary.selfFeedback, + practiceRecords = practiceRecords.toUiModel(), isPast = isPast, ) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt index bb9d0e26..4d2d204f 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt @@ -1,10 +1,42 @@ package com.team.prezel.feature.report.impl.model import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.PracticeRecords +import com.team.prezel.core.ui.component.PracticeCardItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.datetime.DatePeriod import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus @Immutable -data class PracticeUiModel( - val date: LocalDate, - val isPracticed: Boolean, -) +internal data class PracticeRecordsUiModel( + private val practicedDates: List, + val startDate: LocalDate, + val endDate: LocalDate, +) { + val practices: ImmutableList = buildList { + val practicedDateSet = practicedDates.toSet() + var currentDate = startDate + + while (currentDate <= endDate) { + add( + PracticeCardItem( + date = currentDate, + isPracticed = currentDate in practicedDateSet, + ), + ) + + currentDate = currentDate.plus(DatePeriod(days = 1)) + } + }.toImmutableList() + + companion object { + fun PracticeRecords.toUiModel(): PracticeRecordsUiModel = + PracticeRecordsUiModel( + practicedDates = dates, + startDate = startDate, + endDate = endDate, + ) + } +} diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/preview/ReportPreviewUiState.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/preview/ReportPreviewUiState.kt index e65b07f9..1dbc44df 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/preview/ReportPreviewUiState.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/preview/ReportPreviewUiState.kt @@ -8,11 +8,13 @@ import com.team.prezel.core.model.presentation.Style import com.team.prezel.feature.report.impl.contract.AnalysisReportUiState import com.team.prezel.feature.report.impl.model.GrowthGraphData import com.team.prezel.feature.report.impl.model.GrowthGraphItemUiModel +import com.team.prezel.feature.report.impl.model.PracticeRecordsUiModel import com.team.prezel.feature.report.impl.model.PresentationInfoUiModel import com.team.prezel.feature.report.impl.model.QuestionUiModel import com.team.prezel.feature.report.impl.model.ScriptAnalysisGraphData import com.team.prezel.feature.report.impl.model.SpeedGraphData import kotlinx.collections.immutable.persistentListOf +import kotlinx.datetime.LocalDate private val ReportPreviewBaseUiState: AnalysisReportUiState.Content = AnalysisReportUiState.Content( presentationInfo = PresentationInfoUiModel( @@ -59,6 +61,15 @@ private val ReportPreviewBaseUiState: AnalysisReportUiState.Content = AnalysisRe ), selfFeedback = "아 발표 드디어 끝났다", isPast = false, + practiceRecords = PracticeRecordsUiModel( + practicedDates = listOf( + LocalDate(2026, 5, 10), + LocalDate(2026, 5, 12), + LocalDate(2026, 5, 13), + ), + startDate = LocalDate(2026, 5, 10), + endDate = LocalDate(2026, 5, 14), + ), ) internal val ReportPreviewPastUiState: AnalysisReportUiState.Content = ReportPreviewBaseUiState.copy(isPast = true) From b077800e17d7e3a02641fb163f5cb34682c33c2c Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 20:49:19 +0900 Subject: [PATCH 08/12] =?UTF-8?q?refactor:=20PrezelButton=20FILLED=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EC=9D=98=20=EB=B0=B0=EA=B2=BD=EC=83=89=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: ButtonType.FILLED의 배경색 테마 컬러 변경** * `ButtonType.FILLED`에 적용되던 배경색 색상 값을 `PrezelTheme.colors.bgLarge`에서 `PrezelTheme.colors.bgDisabled`로 변경했습니다. --- .../component/actions/button/config/PrezelButtonDefaults.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/config/PrezelButtonDefaults.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/config/PrezelButtonDefaults.kt index 391a5498..5be2c220 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/config/PrezelButtonDefaults.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/actions/button/config/PrezelButtonDefaults.kt @@ -121,7 +121,7 @@ object PrezelButtonDefaults { ButtonType.GHOST, -> Color.Transparent - ButtonType.FILLED -> PrezelTheme.colors.bgLarge + ButtonType.FILLED -> PrezelTheme.colors.bgDisabled } @Composable From 7a26675bf237de287300e1cb57e996c32c37e2c4 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 20:50:11 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 메인 데이터 처리를 위한 `MainDataBundle` 도입 및 UseCase 개편** * 닉네임과 발표 데이터 목록을 포함하는 `MainDataBundle` 클래스를 추가했습니다. * `FetchMainDataUseCase`에서 `UserRepository`를 통해 사용자의 닉네임을 비동기로 함께 조회하도록 수정했습니다. * `HomeUiState`의 매핑 로직을 `MainDataBundle` 기반으로 변경하여 하드코딩된 임시 닉네임을 실제 데이터로 대체했습니다. * **feat: `UserRepository` 내 닉네임 캐싱 로직 추가** * `UserRepository` 인터페이스에 `getUserNickname` 메서드를 추가했습니다. * `UserRepositoryImpl`에서 `@Volatile` 변수를 사용하여 닉네임을 메모리에 캐싱하고, 정보 조회나 수정 시 캐시를 갱신하도록 구현했습니다. * **refactor: 홈 화면 UI 구성 요소 리팩터링 및 기능 연결** * 홈 화면의 빈 상태를 표시하는 로직을 별도의 `EmptySheet` 컴포넌트로 분리하여 가독성을 높였습니다. * `EmptySheet`에 사용될 문자열 리소스를 추가하고 디자인 시스템의 버튼 및 테마를 적용했습니다. * 홈 화면의 '발표 추가' 버튼 클릭 시 음성 녹음 분석 화면으로 이동하도록 네비게이션 로직을 연결했습니다. --- .../data/repository/UserRepositoryImpl.kt | 12 ++- .../repository/profile/UserRepository.kt | 2 + .../presentation/FetchMainDataUseCase.kt | 15 +++- .../core/model/presentation/MainDataBundle.kt | 6 ++ .../feature/home/impl/main/HomeScreen.kt | 2 +- .../impl/main/component/HomeScreenContent.kt | 23 +----- .../impl/main/component/body/EmptySheet.kt | 76 +++++++++++++++++++ .../home/impl/main/contract/HomeUiState.kt | 12 +-- .../home/impl/src/main/res/values/strings.xml | 4 + 9 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataBundle.kt create mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptySheet.kt diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt index 1256ad02..c9299065 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/UserRepositoryImpl.kt @@ -12,13 +12,22 @@ import javax.inject.Inject internal class UserRepositoryImpl @Inject constructor( private val userRemoteDataSource: UserRemoteDataSource, ) : UserRepository { + @Volatile + private var cachedNickname: String = "" + override suspend fun fetchUserInfo(): Result = runCatching { userRemoteDataSource.getUser() }.mapCatching { response -> - response.toDomain() + response.toDomain().also { user -> cachedNickname = user.nickname } }.mapDomainFailure() + override suspend fun getUserNickname(): Result { + if (cachedNickname.isNotBlank()) return Result.success(cachedNickname) + + return runCatching { fetchUserInfo().getOrThrow().nickname } + } + override suspend fun patchProfile( nickname: String, profileImageFile: File?, @@ -28,6 +37,7 @@ internal class UserRepositoryImpl @Inject constructor( nickname = nickname, profileImageFile = profileImageFile, ) + cachedNickname = nickname }.mapDomainFailure() override suspend fun checkNicknameDuplication(nickname: Nickname): Result = diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt index 6ca71c13..8370c02e 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/profile/UserRepository.kt @@ -7,6 +7,8 @@ import java.io.File interface UserRepository { suspend fun fetchUserInfo(): Result + suspend fun getUserNickname(): Result + suspend fun patchProfile( nickname: String, profileImageFile: File?, diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt index f5e84e92..4ac1c6e5 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt @@ -1,7 +1,9 @@ package com.team.prezel.core.domain.usecase.presentation import com.team.prezel.core.domain.repository.presentation.PresentationRepository +import com.team.prezel.core.domain.repository.profile.UserRepository import com.team.prezel.core.model.presentation.MainData +import com.team.prezel.core.model.presentation.MainDataBundle import com.team.prezel.core.model.presentation.MainDataWithPracticeRecords import com.team.prezel.core.model.presentation.PracticeRecords import kotlinx.coroutines.async @@ -11,13 +13,17 @@ import javax.inject.Inject class FetchMainDataUseCase @Inject constructor( private val repository: PresentationRepository, + private val userRepository: UserRepository, ) { - suspend operator fun invoke(): Result> = + suspend operator fun invoke(): Result = repository.getMainData().fold( onSuccess = { mainData -> runCatching { coroutineScope { - mainData + val nicknameDeferred = async { + userRepository.getUserNickname().getOrThrow() + } + val presentations = mainData .map { data -> async { val practiceRecords = repository @@ -26,6 +32,11 @@ class FetchMainDataUseCase @Inject constructor( data.toMainDataWithPracticeRecords(practiceRecords = practiceRecords) } }.awaitAll() + + MainDataBundle( + nickname = nicknameDeferred.await(), + presentations = presentations, + ) } } }, diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataBundle.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataBundle.kt new file mode 100644 index 00000000..e8597ba8 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainDataBundle.kt @@ -0,0 +1,6 @@ +package com.team.prezel.core.model.presentation + +data class MainDataBundle( + val nickname: String, + val presentations: List, +) diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt index c32df79d..7996c65c 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/HomeScreen.kt @@ -60,7 +60,7 @@ internal fun HomeScreen( HomeScreenContent( uiState = uiState, pagerState = pagerState, - onClickAddPresentation = { }, + onClickAddPresentation = navigateToVoiceRecordingAnalysis, onClickPracticeRecording = { presentationId -> navigateToPracticeRecording(presentationId) }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt index 48bf7b5a..b2c1cc92 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt @@ -2,10 +2,7 @@ package com.team.prezel.feature.home.impl.main.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.ExperimentalMaterial3Api @@ -16,15 +13,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.team.prezel.core.designsystem.component.actions.button.PrezelButton -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.main.component.body.HomeBottomSheetContent -import com.team.prezel.feature.home.impl.main.component.body.HomeBottomSheetTitle +import com.team.prezel.feature.home.impl.main.component.body.EmptySheet 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 @@ -141,18 +133,7 @@ private fun HomeEmptyContent( HomePageLayout( maxHeight = maxHeight, headerHeight = headerHeight, - sheetContent = { - HomeBottomSheetContent( - 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 = {}, - ) - } - }, + sheetContent = { EmptySheet() }, heroContent = { EmptyPresentationHero( nickname = nickname, diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptySheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptySheet.kt new file mode 100644 index 00000000..e4b7e078 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptySheet.kt @@ -0,0 +1,76 @@ +package com.team.prezel.feature.home.impl.main.component.body + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.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.unit.dp +import com.team.prezel.core.designsystem.component.actions.button.PrezelTextButton +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.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.home.impl.R + +@Composable +internal fun EmptySheet(modifier: Modifier = Modifier) { + HomeBottomSheetContent( + modifier = modifier, + contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), + ) { + HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_empty_sheet_practice_card_title)) + Column( + modifier = Modifier + .fillMaxWidth() + .height(136.dp) + .clip(PrezelTheme.shapes.V6) + .background(PrezelTheme.colors.bgMedium) + .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.feature_home_impl_empty_sheet_practice_card_message), + style = PrezelTheme.typography.body3Regular, + color = PrezelTheme.colors.textDisabled, + ) + } + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + + PrezelTextButton( + text = stringResource(R.string.feature_home_impl_empty_sheet_practice_card_button_text), + type = ButtonType.FILLED, + hierarchy = ButtonHierarchy.PRIMARY, + size = ButtonSize.SMALL, + modifier = Modifier.fillMaxWidth(), + enabled = false, + onClick = {}, + ) + } + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + } +} + +@BasicPreview +@Composable +private fun EmptySheetPreview() { + PrezelTheme { + EmptySheet() + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt index 6d2a6523..6d0cb992 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/contract/HomeUiState.kt @@ -1,7 +1,7 @@ package com.team.prezel.feature.home.impl.main.contract import androidx.compose.runtime.Immutable -import com.team.prezel.core.model.presentation.MainDataWithPracticeRecords +import com.team.prezel.core.model.presentation.MainDataBundle import com.team.prezel.core.ui.base.UiState import com.team.prezel.feature.home.impl.main.model.PresentationUiModel import com.team.prezel.feature.home.impl.main.model.PresentationUiModel.Companion.toUiModel @@ -35,10 +35,12 @@ internal sealed interface HomeUiState : UiState { } companion object { - fun List.toUiState(): HomeUiState { - val uiModels = map { data -> data.toUiModel() } - return when (size) { - 0 -> Empty(nickname = "TEMP") + fun MainDataBundle.toUiState(): HomeUiState { + val uiModels = presentations.map { data -> data.toUiModel() } + val fallbackNickname = nickname.ifBlank { "unknown" } + + return when (presentations.size) { + 0 -> Empty(nickname = fallbackNickname) 1 -> SingleContent(presentation = uiModels.first()) else -> MultipleContent(presentations = uiModels.toImmutableList()) } 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 a2feca9b..768d87d8 100644 --- a/Prezel/feature/home/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -24,6 +24,10 @@ 연습하기 + 발표를 추가하고 연습을 시작해보세요! + 연습하기 + 지금부터 연습해보세요 + 데이터를 불러오지 못했습니다. From 154383f0578875d270531d82409ad0071a192071 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 20:51:03 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20PracticeUiModel=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: UI 모델 클래스 이름 변경** * `PracticeUiModel`을 보다 구체적인 명칭인 `PracticeRecordsUiModel`로 변경했습니다. --- .../impl/model/{PracticeUiModel.kt => PracticeRecordsUiModel.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/{PracticeUiModel.kt => PracticeRecordsUiModel.kt} (100%) diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeRecordsUiModel.kt similarity index 100% rename from Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt rename to Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeRecordsUiModel.kt From 01695e1934eed98c6e947643dc6ce655bb200470 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 20:57:55 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20Intent=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: `ProfileViewModel` 초기화 시 데이터 로드 수행** * 기존 UI 레이어에서 `FetchData` Intent를 통해 데이터를 요청하던 방식에서, ViewModel의 `init` 블록 내에서 `fetchUserInfo()`를 직접 호출하도록 변경했습니다. * **cleanup: `ProfileUiIntent.FetchData` 및 관련 코드 제거** * 더 이상 사용되지 않는 `ProfileUiIntent.FetchData` 정의를 삭제했습니다. * `ProfileScreen`의 `LaunchedEffect`에서 `FetchData` Intent를 전달하던 로직을 제거하고, ViewModel의 `onIntent` 내 해당 처리 케이스를 삭제했습니다. --- .../java/com/team/prezel/feature/profile/impl/ProfileScreen.kt | 1 - .../com/team/prezel/feature/profile/impl/ProfileViewModel.kt | 3 ++- .../prezel/feature/profile/impl/contract/ProfileUiIntent.kt | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt index 67046e02..ab4e9c29 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileScreen.kt @@ -62,7 +62,6 @@ internal fun ProfileScreen( } LaunchedEffect(Unit) { - viewModel.onIntent(ProfileUiIntent.FetchData) viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToHome -> navigateToHome() diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt index c6919d9a..fad99645 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/ProfileViewModel.kt @@ -41,11 +41,12 @@ internal class ProfileViewModel @Inject constructor( .distinctUntilChanged() .collectLatest(::validateNickname) } + + fetchUserInfo() } override fun onIntent(intent: ProfileUiIntent) { when (intent) { - ProfileUiIntent.FetchData -> fetchUserInfo() is ProfileUiIntent.UpdateNickname -> handleNicknameChanged(intent.nickname) is ProfileUiIntent.UpdateProfileImage -> handleProfileImageChanged( profileUrl = intent.profileUrl, diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt index b58920bb..f77d74bb 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiIntent.kt @@ -4,8 +4,6 @@ import com.team.prezel.core.ui.base.UiIntent import java.io.File internal sealed interface ProfileUiIntent : UiIntent { - data object FetchData : ProfileUiIntent - data class UpdateNickname( val nickname: String, ) : ProfileUiIntent From 5c5617e19a77f48b7dd2324f5d1fe9cc88a55848 Mon Sep 17 00:00:00 2001 From: moondev03 Date: Fri, 29 May 2026 21:22:21 +0900 Subject: [PATCH 12/12] =?UTF-8?q?refactor:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=ED=95=98=EB=8B=A8=20=EC=8B=9C=ED=8A=B8=20UI=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **refactor: 발표 데이터 표시 로직 개선 및 미사용 컴포넌트 제거** * `PresentationSheet`에서 과거 발표(`PresentationUiModel.Past`)의 그래프 데이터가 비어있는 경우, 하단 시트 콘텐츠를 렌더링하지 않도록 가드 로직을 추가했습니다. * 더 이상 사용되지 않는 `EmptyPresentationSheet.kt` 컴포넌트 파일을 삭제했습니다. * **cleanup: 불필요한 디버그 코드 제거** * `AnalyzePresentationUseCase` 내 성공 콜백에 남아있던 디버그용 `println` 호출을 삭제했습니다. --- .../AnalyzePresentationUseCase.kt | 2 +- .../component/body/EmptyPresentationSheet.kt | 44 ------------------- .../main/component/body/PresentationSheet.kt | 2 + 3 files changed, 3 insertions(+), 45 deletions(-) delete mode 100644 Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt index ec48cf88..74b3731e 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt @@ -32,5 +32,5 @@ class AnalyzePresentationUseCase @Inject constructor( script = script, scriptFilePath = scriptFilePath, audioFilePath = audioFilePath, - ).onSuccess { println(date) } + ) } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt deleted file mode 100644 index 270dd205..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt +++ /dev/null @@ -1,44 +0,0 @@ -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) { - 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 = {}, - ) - } -} - -@BasicPreview -@Composable -private fun EmptyPresentationContentPreview() { - PrezelTheme { - Box( - modifier = Modifier - .height(100.dp) - .padding(top = 16.dp), - ) { - EmptyPresentationSheet() - } - } -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt index b075b3c5..e8a84bf3 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/PresentationSheet.kt @@ -41,6 +41,8 @@ internal fun PresentationSheet( when (presentation) { is PresentationUiModel.Past -> { + if (presentation.growthGraphData.graphItems.isEmpty()) return@HomeBottomSheetContent + HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_past_graph_title)) CardGraph( items = presentation.growthGraphData.graphItems,