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..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,10 +1,14 @@ 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 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 @@ -21,11 +25,15 @@ 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 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 +46,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,47 +70,75 @@ 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() } - } - } - - 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)) - }, + Box(modifier = Modifier.fillMaxSize()) { + SharedTransitionLayout { + ProvideSharedTransitionScope(this@SharedTransitionLayout) { + AppNavigationContent( + appState = appState, + entryBuilders = entryBuilders, + navigator = navigator, ) } } + + 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/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..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 @@ -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,28 @@ 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, + growthGraph = growthGraph?.map { item -> item.toDomain() }.orEmpty(), + ) + +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/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/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/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/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 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/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/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/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/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/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/AnalyzePresentationUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/AnalyzePresentationUseCase.kt index cd6ea59a..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 @@ -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, + ) } 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..4ac1c6e5 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt @@ -0,0 +1,57 @@ +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 +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +class FetchMainDataUseCase @Inject constructor( + private val repository: PresentationRepository, + private val userRepository: UserRepository, +) { + suspend operator fun invoke(): Result = + repository.getMainData().fold( + onSuccess = { mainData -> + runCatching { + coroutineScope { + val nicknameDeferred = async { + userRepository.getUserNickname().getOrThrow() + } + val presentations = mainData + .map { data -> + async { + val practiceRecords = repository + .getPracticeRecords(presentationId = data.presentationId) + .getOrThrow() + data.toMainDataWithPracticeRecords(practiceRecords = practiceRecords) + } + }.awaitAll() + + MainDataBundle( + nickname = nicknameDeferred.await(), + presentations = presentations, + ) + } + } + }, + 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/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/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/MainData.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt new file mode 100644 index 00000000..2f966227 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/MainData.kt @@ -0,0 +1,13 @@ +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 growthGraph: List, +) 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/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/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/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/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..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 @@ -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,43 +86,48 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( override suspend fun getPastPresentationDetail(presentationId: Long): PresentationSummaryResponse = presentationService.getPastPresentationDetail(presentationId = presentationId).requireData().analysisResult - private fun String.toAudioMultipart(): MultiPartFormDataContent = - MultiPartFormDataContent( - formData { - appendAudioPart(this@toAudioMultipart) - }, - ) + override suspend fun getPracticeRecords(presentationId: Long): GetPracticeRecordsResponse = + presentationService.getPracticeRecords(presentationId = presentationId).requireData() - private fun FormBuilder.appendAudioPart(audioFilePath: String) { - val audioFile = File(audioFilePath) + override suspend fun getMainData(): List = presentationService.getMainData().requireData() +} - 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/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..dbf2d412 --- /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/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/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> } 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, 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") +} 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..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 @@ -1,61 +1,37 @@ 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.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.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.graphics.Color import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp 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 @Composable internal fun HomeScreen( + navigateToPracticeRecording: (presentationId: Long) -> Unit, navigateToFileUploadAnalysis: () -> Unit, navigateToVoiceRecordingAnalysis: () -> Unit, modifier: Modifier = Modifier, @@ -65,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) @@ -82,296 +57,53 @@ internal fun HomeScreen( } } - HomeScreen( + HomeScreenContent( uiState = uiState, pagerState = pagerState, - onClickAddPresentation = { }, - onClickPracticeRecording = { navigator.navigate(PracticeNavKey) }, + onClickAddPresentation = navigateToVoiceRecordingAnalysis, + onClickPracticeRecording = { presentationId -> navigateToPracticeRecording(presentationId) }, onClickAnalyzePresentation = { }, onClickWriteFeedback = { }, onClickVoiceRecordingAnalysis = navigateToVoiceRecordingAnalysis, onClickFileUploadAnalysis = navigateToFileUploadAnalysis, - modifier = modifier, - ) -} - -@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, - modifier: Modifier = Modifier, -) { - val scope = rememberCoroutineScope() - var isFabExpanded by remember { mutableStateOf(false) } - - 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, - ) - - HomeHeadSection( - uiState = uiState, - pagerState = pagerState, - onClickTab = { pageIndex -> scope.launch { pagerState.scrollToPage(pageIndex) } }, - modifier = Modifier.onHeightChanged { newHeight -> headerHeight = newHeight }, - ) - - if (isFabExpanded) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.32f)) - .clickable { isFabExpanded = false }, - ) - } - - HomeAnalysisFloatingMenu( - isExpanded = isFabExpanded, - onChangeExpanded = { isFabExpanded = it }, - onClickVoiceRecording = { - isFabExpanded = false - onClickVoiceRecordingAnalysis() - }, - onClickFileUpload = { - isFabExpanded = false - onClickFileUploadAnalysis() - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = PrezelTheme.spacing.V24, bottom = PrezelTheme.spacing.V24), - ) - } - } -} - -@Composable -private fun HomeAnalysisFloatingMenu( - isExpanded: Boolean, - onChangeExpanded: (Boolean) -> Unit, - onClickVoiceRecording: () -> Unit, - onClickFileUpload: () -> Unit, - modifier: Modifier = Modifier, -) { - PrezelFloatingMenu( - isExpanded = isExpanded, - onChangeExpanded = onChangeExpanded, - iconResId = PrezelIcons.Plus, - openIconResId = PrezelIcons.Cancel, - size = ButtonSize.REGULAR, - hierarchy = ButtonHierarchy.PRIMARY, - modifier = modifier, - ) { - 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, - ) - } -} - -@Composable -private fun HomeContent( - uiState: HomeUiState, - pagerState: PagerState, - maxHeight: Dp, - headerHeight: Dp, - onClickAddPresentation: () -> Unit, - onClickPracticeRecording: () -> Unit, - onClickAnalyzePresentation: (PresentationUiModel) -> Unit, - onClickWriteFeedback: (PresentationUiModel) -> 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, - ) - } - - is HomeUiState.MultipleContent -> { - HomeMultipleContent( - uiState = uiState, - pagerState = pagerState, - maxHeight = maxHeight, - headerHeight = headerHeight, - onClickPracticeRecording = onClickPracticeRecording, - onClickAnalyzePresentation = onClickAnalyzePresentation, - onClickWriteFeedback = onClickWriteFeedback, - ) - } - } -} - -@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, -) { - val presentation = uiState.presentation - - HomePageLayout( - maxHeight = maxHeight, - headerHeight = headerHeight, - sheetContent = { - PresentationSheet( - practiceCount = presentation.practiceCount, - onClickPracticeRecording = onClickPracticeRecording, - ) - }, - heroContent = { - PresentationHero( - presentation = presentation, - onClickAnalyzePresentation = onClickAnalyzePresentation, - onClickWriteFeedback = onClickWriteFeedback, - ) + onClickCardGraphItemIndex = { presentationId, index -> + viewModel.onIntent(HomeUiIntent.ClickCardGraphItem(presentationId = presentationId, index = index)) }, + modifier = modifier, ) } -@Composable -private fun HomeMultipleContent( - uiState: HomeUiState.MultipleContent, - pagerState: PagerState, - maxHeight: Dp, - headerHeight: Dp, - onClickPracticeRecording: () -> Unit, - onClickAnalyzePresentation: (PresentationUiModel) -> Unit, - onClickWriteFeedback: (PresentationUiModel) -> 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( - practiceCount = presentation.practiceCount, - onClickPracticeRecording = onClickPracticeRecording, - ) - }, - 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 = { }, - ) - } + HomeScreenPreview(uiState = uiState) } @BasicPreview @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 = "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 = 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 = { }, - ) - } + HomeScreenPreview(uiState = uiState) } @BasicPreview @@ -379,25 +111,40 @@ 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(), ) + HomeScreenPreview(uiState = uiState) +} + +@Composable +private fun HomeScreenPreview(uiState: HomeUiState) { PrezelTheme { - HomeScreen( - uiState = uiState, - pagerState = rememberPagerState(0) { uiState.presentationCount() }, - onClickAddPresentation = { }, - onClickPracticeRecording = { }, - onClickAnalyzePresentation = { }, - onClickWriteFeedback = { }, - onClickVoiceRecordingAnalysis = { }, - onClickFileUploadAnalysis = { }, - ) + 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/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/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..b2c1cc92 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt @@ -0,0 +1,206 @@ +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.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 +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: (presentationId: Long) -> 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: (presentationId: Long) -> 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, + ) + } + + is HomeUiState.SingleContent -> { + HomePresentationContent( + presentation = uiState.presentation, + maxHeight = maxHeight, + headerHeight = headerHeight, + onClickPracticeRecording = { onClickPracticeRecording(uiState.presentation.id) }, + 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, +) { + HomePageLayout( + maxHeight = maxHeight, + headerHeight = headerHeight, + sheetContent = { EmptySheet() }, + 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: (presentationId: Long) -> 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(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 deleted file mode 100644 index 0ee5c218..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/body/EmptyPresentationSheet.kt +++ /dev/null @@ -1,47 +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( - onClickPracticeRecording: () -> Unit, - modifier: Modifier = Modifier, -) { - HomeBottomSheetContent( - modifier = modifier, - contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), - ) { - HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_empty_title)) - Spacer(modifier = Modifier.height(12.dp)) - PrezelButton( - text = stringResource(R.string.feature_home_impl_practice_recording_action), - onClick = onClickPracticeRecording, - ) - } -} - -@BasicPreview -@Composable -private fun EmptyPresentationContentPreview() { - PrezelTheme { - Box( - modifier = Modifier - .height(100.dp) - .padding(top = 16.dp), - ) { - EmptyPresentationSheet(onClickPracticeRecording = {}) - } - } -} 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/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..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 @@ -9,36 +9,53 @@ 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 -> { + 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, + 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 +63,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 = "D-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..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,8 +1,10 @@ package com.team.prezel.feature.home.impl.main.contract import androidx.compose.runtime.Immutable +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 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,15 @@ 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 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/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..44fcebbd --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/model/GrowthGraphItemUiModel.kt @@ -0,0 +1,43 @@ +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 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/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/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml index 4a7d40fc..768d87d8 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일 @@ -22,6 +24,10 @@ 연습하기 + 발표를 추가하고 연습을 시작해보세요! + 연습하기 + 지금부터 연습해보세요 + 데이터를 불러오지 못했습니다. 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/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 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/PracticeRecordsUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeRecordsUiModel.kt new file mode 100644 index 00000000..4d2d204f --- /dev/null +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeRecordsUiModel.kt @@ -0,0 +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 +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/model/PracticeUiModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt deleted file mode 100644 index bb9d0e26..00000000 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/model/PracticeUiModel.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.team.prezel.feature.report.impl.model - -import androidx.compose.runtime.Immutable -import kotlinx.datetime.LocalDate - -@Immutable -data class PracticeUiModel( - val date: LocalDate, - val isPracticed: Boolean, -) 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)