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 b1154cd0..27285164 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 @@ -11,6 +11,7 @@ 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.BoxScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -25,6 +26,7 @@ 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -53,6 +55,7 @@ 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.LocalSnackbarCoroutineScope import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.core.ui.state.rememberAppDimmerState import com.team.prezel.core.ui.util.noRippleClickable @@ -70,11 +73,13 @@ fun PrezelApp( ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } val snackbarHostState = remember { SnackbarHostState() } + val snackbarCoroutineScope = rememberCoroutineScope() val appDimmerState = rememberAppDimmerState() CompositionLocalProvider( LocalNavigator provides navigator, LocalAppDimmerState provides appDimmerState, + LocalSnackbarCoroutineScope provides snackbarCoroutineScope, LocalSnackbarHostState provides snackbarHostState, ) { DoubleBackToExitHandler(navigationState = appState.navigationState) @@ -124,7 +129,11 @@ private fun PrezelAppContent( } EdgeToEdgeStatusBarBackground(style = statusBarStyle) - AppDimmerOverlay(isVisible = appDimmerState.isVisible, onDismiss = appDimmerState::dismiss) + AppDimmerOverlay( + isVisible = appDimmerState.isVisible, + foregroundContent = appDimmerState.foregroundContent, + onDismiss = appDimmerState::dismiss, + ) } } @@ -159,16 +168,23 @@ private fun AppNavigationContent( @Composable private fun AppDimmerOverlay( isVisible: Boolean, + foregroundContent: (@Composable BoxScope.() -> Unit)?, onDismiss: () -> Unit, ) { if (!isVisible) return Box( - modifier = Modifier - .fillMaxSize() - .background(PrezelTheme.colors.scrimContainer) - .noRippleClickable(onClick = onDismiss), - ) + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(PrezelTheme.colors.scrimContainer) + .noRippleClickable(onClick = onDismiss), + ) + + foregroundContent?.invoke(this) + } } private fun defaultPrezelNavTransition(): ContentTransform = 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 f2ff612e..a89832be 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 @@ -3,6 +3,7 @@ package com.team.prezel.core.data.mapper 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.Curation import com.team.prezel.core.model.presentation.ExpectedQuestion import com.team.prezel.core.model.presentation.MainData import com.team.prezel.core.model.presentation.PracticeRecords @@ -18,6 +19,7 @@ import com.team.prezel.core.model.presentation.SentenceAnalysisDetail import com.team.prezel.core.model.presentation.Style import com.team.prezel.core.model.presentation.WordAnalysisDetail import com.team.prezel.core.model.presentation.WordAnalysisStatus +import com.team.prezel.core.network.model.presentation.GetCurationResponse 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 @@ -151,3 +153,13 @@ private fun GetMainDataResponse.GrowthGraph.toDomain(): PresentationGrowthPoint accuracyScore = accuracyScore, scriptMatchRate = scriptMatchRate, ) + +internal fun GetCurationResponse.toDomain(): Curation = + Curation( + guideMessage = guideMessage, + materialType = materialType, + title = title, + sourceChannel = sourceChannel, + linkUrl = linkUrl, + imageUrl = imageUrl, + ) diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt index 512e8c5c..952d7c3d 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/BadgeRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.team.prezel.core.domain.repository.badge.BadgeRepository import com.team.prezel.core.model.badge.Badge import com.team.prezel.core.model.badge.BadgeDetail import com.team.prezel.core.model.badge.BadgeEvent +import com.team.prezel.core.model.badge.BadgeSortType import com.team.prezel.core.network.datasource.BadgeRemoteDataSource import com.team.prezel.core.network.model.badge.BadgeEventResponse import com.team.prezel.core.network.model.badge.GetBadgeDetailResponse @@ -16,9 +17,9 @@ import javax.inject.Inject internal class BadgeRepositoryImpl @Inject constructor( private val badgeRemoteDataSource: BadgeRemoteDataSource, ) : BadgeRepository { - override suspend fun getBadges(): Result> = + override suspend fun getBadges(sort: BadgeSortType): Result> = runCatching { - badgeRemoteDataSource.getBadges() + badgeRemoteDataSource.getBadges(sort.value.lowercase()) }.mapCatching { response -> response.map(GetBadgeResponse::toDomain) }.mapDomainFailure() 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 e1e2e491..7f957791 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,7 @@ 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.Curation import com.team.prezel.core.model.presentation.MainData import com.team.prezel.core.model.presentation.PracticeRecords import com.team.prezel.core.model.presentation.PresentationAnalysisSummary @@ -14,6 +15,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.GetCurationResponse import com.team.prezel.core.network.model.presentation.GetMainDataResponse import com.team.prezel.core.network.model.presentation.GetPresentationsResponse import javax.inject.Inject @@ -136,4 +138,11 @@ internal class PresentationRepositoryImpl @Inject constructor( content = content, ) }.mapDomainFailure() + + override suspend fun getCuration(presentationId: Long): Result> = + runCatching { + presentationRemoteDataSource.getCuration(presentationId = presentationId) + }.mapCatching { response -> + response.map(GetCurationResponse::toDomain) + }.mapDomainFailure() } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/TermsRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/TermsRepositoryImpl.kt index 15873106..f4bb7340 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/TermsRepositoryImpl.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/TermsRepositoryImpl.kt @@ -2,14 +2,23 @@ package com.team.prezel.core.data.repository import com.team.prezel.core.data.error.mapDomainFailure import com.team.prezel.core.domain.repository.terms.TermsRepository +import com.team.prezel.core.model.terms.Term import com.team.prezel.core.model.terms.TermsAgreement import com.team.prezel.core.network.datasource.TermsRemoteDataSource import com.team.prezel.core.network.model.terms.AgreeTermsRequest +import com.team.prezel.core.network.model.terms.FetchTermsResponse import javax.inject.Inject internal class TermsRepositoryImpl @Inject constructor( private val termsRemoteDataSource: TermsRemoteDataSource, ) : TermsRepository { + override suspend fun fetchTerms(): Result> = + runCatching { + termsRemoteDataSource.fetchTerms() + }.mapCatching { response -> + response.map { term -> term.toDomain() } + }.mapDomainFailure() + override suspend fun agreeTerms(terms: List): Result = runCatching { termsRemoteDataSource.agreeTerms( @@ -18,4 +27,14 @@ internal class TermsRepositoryImpl @Inject constructor( }, ) }.mapDomainFailure() + + private fun FetchTermsResponse.toDomain(): Term = + Term( + termsId = termsId.toLong(), + title = title, + summary = summary, + content = content, + isRequired = isRequired, + version = version, + ) } diff --git a/Prezel/core/designsystem/build.gradle.kts b/Prezel/core/designsystem/build.gradle.kts index 75aa9036..9f0ace45 100644 --- a/Prezel/core/designsystem/build.gradle.kts +++ b/Prezel/core/designsystem/build.gradle.kts @@ -10,6 +10,7 @@ android { dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.coil.kt.compose) + implementation(libs.coil.kt.svg) implementation(libs.kotlinx.datetime) implementation(libs.timber) diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt index 13e98d7c..0c4b19de 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelAsyncImage.kt @@ -1,11 +1,15 @@ package com.team.prezel.core.designsystem.component import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.ImageLoader import coil.compose.AsyncImage +import coil.decode.SvgDecoder import timber.log.Timber @Composable @@ -17,8 +21,18 @@ fun PrezelAsyncImage( onSuccess: () -> Unit = {}, onError: (Throwable?) -> Unit = {}, ) { + val context = LocalContext.current + val imageLoader = remember(context) { + ImageLoader + .Builder(context) + .components { + add(SvgDecoder.Factory()) + }.build() + } + AsyncImage( model = url, + imageLoader = imageLoader, contentDescription = contentDescription, modifier = modifier, placeholder = ColorPainter(Color.Transparent), diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt index cde34494..7be0e140 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/textfield/PrezelTextField.kt @@ -3,12 +3,13 @@ package com.team.prezel.core.designsystem.component.textfield import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.BasicTextField @@ -29,7 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -43,6 +44,7 @@ import com.team.prezel.core.designsystem.preview.PreviewColumn import com.team.prezel.core.designsystem.preview.PreviewSurface import com.team.prezel.core.designsystem.theme.PrezelTheme +@OptIn(ExperimentalLayoutApi::class) @Composable fun PrezelTextField( value: String, @@ -57,11 +59,11 @@ fun PrezelTextField( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, ) { + val focusManager = LocalFocusManager.current + val isImeVisible = WindowInsets.isImeVisible var focused by remember { mutableStateOf(false) } + var wasImeVisible by remember { mutableStateOf(isImeVisible) } var textFieldValue by remember { mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length))) } - val density = LocalDensity.current - val imeVisible = WindowInsets.ime.getBottom(density) > 0 - val typing = focused && imeVisible LaunchedEffect(value) { if (value != textFieldValue.text) { @@ -69,10 +71,17 @@ fun PrezelTextField( } } + LaunchedEffect(isImeVisible, focused, enabled) { + if (enabled && focused && wasImeVisible && !isImeVisible) { + focusManager.clearFocus() + } + wasImeVisible = isImeVisible + } + val state = rememberPrezelTextFieldState( value = textFieldValue.text, enabled = enabled, - focused = typing, + focused = focused, ).let { state -> PrezelTextFieldStyle(state = state, status = status) } PrezelTextField( @@ -86,7 +95,7 @@ fun PrezelTextField( }, placeholder = placeholder, style = state, - focused = typing, + focused = focused, onFocusChange = { isFocused -> focused = isFocused }, modifier = modifier, label = label, diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/theme/PrezelTheme.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/theme/PrezelTheme.kt index c775a347..f99d9b7e 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/theme/PrezelTheme.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/theme/PrezelTheme.kt @@ -58,7 +58,7 @@ fun PrezelTheme( isDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { - val colorScheme = if (isDarkTheme) PrezelColorScheme.Dark else PrezelColorScheme.Light + val colorScheme = if (isDarkTheme) PrezelColorScheme.Light else PrezelColorScheme.Light val typographyScheme = PrezelTypographyScheme.Default() CompositionLocalProvider( diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt index 02d7d34f..b09769bd 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/badge/BadgeRepository.kt @@ -3,10 +3,11 @@ package com.team.prezel.core.domain.repository.badge import com.team.prezel.core.model.badge.Badge import com.team.prezel.core.model.badge.BadgeDetail import com.team.prezel.core.model.badge.BadgeEvent +import com.team.prezel.core.model.badge.BadgeSortType import kotlinx.coroutines.flow.Flow interface BadgeRepository { - suspend fun getBadges(): Result> + suspend fun getBadges(sort: BadgeSortType): Result> suspend fun getBadgeDetail(badgeCode: 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 9379e68f..80a52187 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,7 @@ 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.Curation import com.team.prezel.core.model.presentation.MainData import com.team.prezel.core.model.presentation.PracticeRecords import com.team.prezel.core.model.presentation.PresentationAnalysisSummary @@ -53,4 +54,6 @@ interface PresentationRepository { presentationId: Long, content: String, ): Result + + suspend fun getCuration(presentationId: Long): Result> } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/terms/TermsRepository.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/terms/TermsRepository.kt index 8e710164..83afad6c 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/terms/TermsRepository.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/repository/terms/TermsRepository.kt @@ -1,7 +1,10 @@ package com.team.prezel.core.domain.repository.terms +import com.team.prezel.core.model.terms.Term import com.team.prezel.core.model.terms.TermsAgreement interface TermsRepository { + suspend fun fetchTerms(): Result> + suspend fun agreeTerms(terms: List): Result } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt index 76f62bd6..3112b837 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/badge/FetchBadgesUseCase.kt @@ -2,10 +2,11 @@ package com.team.prezel.core.domain.usecase.badge import com.team.prezel.core.domain.repository.badge.BadgeRepository import com.team.prezel.core.model.badge.Badge +import com.team.prezel.core.model.badge.BadgeSortType import javax.inject.Inject class FetchBadgesUseCase @Inject constructor( private val badgeRepository: BadgeRepository, ) { - suspend operator fun invoke(): Result> = badgeRepository.getBadges() + suspend operator fun invoke(sort: BadgeSortType = BadgeSortType.DEFAULT): Result> = badgeRepository.getBadges(sort) } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchCurationUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchCurationUseCase.kt new file mode 100644 index 00000000..bb0dafdc --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchCurationUseCase.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.Curation +import javax.inject.Inject + +class FetchCurationUseCase @Inject constructor( + private val presentationRepository: PresentationRepository, +) { + suspend operator fun invoke(presentationId: Long): Result> = presentationRepository.getCuration(presentationId = presentationId) +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt index 4ac1c6e5..a77fb3c9 100644 --- a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/presentation/FetchMainDataUseCase.kt @@ -2,6 +2,7 @@ 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.Curation import com.team.prezel.core.model.presentation.MainData import com.team.prezel.core.model.presentation.MainDataBundle import com.team.prezel.core.model.presentation.MainDataWithPracticeRecords @@ -14,22 +15,22 @@ import javax.inject.Inject class FetchMainDataUseCase @Inject constructor( private val repository: PresentationRepository, private val userRepository: UserRepository, + private val fetchCurationUseCase: FetchCurationUseCase, ) { suspend operator fun invoke(): Result = repository.getMainData().fold( onSuccess = { mainData -> runCatching { coroutineScope { - val nicknameDeferred = async { - userRepository.getUserNickname().getOrThrow() - } + val nicknameDeferred = async { userRepository.getUserNickname().getOrThrow() } val presentations = mainData .map { data -> async { val practiceRecords = repository .getPracticeRecords(presentationId = data.presentationId) .getOrThrow() - data.toMainDataWithPracticeRecords(practiceRecords = practiceRecords) + val curations = fetchCurationUseCase(data.presentationId).getOrDefault(emptyList()) + data.toMainDataWithPracticeRecords(practiceRecords = practiceRecords, curations = curations) } }.awaitAll() @@ -43,7 +44,10 @@ class FetchMainDataUseCase @Inject constructor( onFailure = { throwable -> Result.failure(throwable) }, ) - private fun MainData.toMainDataWithPracticeRecords(practiceRecords: PracticeRecords): MainDataWithPracticeRecords = + private fun MainData.toMainDataWithPracticeRecords( + practiceRecords: PracticeRecords, + curations: List, + ): MainDataWithPracticeRecords = MainDataWithPracticeRecords( presentationId = presentationId, title = title, @@ -53,5 +57,6 @@ class FetchMainDataUseCase @Inject constructor( dDay = dDay, growthGraph = growthGraph, practiceRecords = practiceRecords, + curations = curations, ) } diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/terms/FetchTermsUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/terms/FetchTermsUseCase.kt new file mode 100644 index 00000000..34648902 --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/terms/FetchTermsUseCase.kt @@ -0,0 +1,11 @@ +package com.team.prezel.core.domain.usecase.terms + +import com.team.prezel.core.domain.repository.terms.TermsRepository +import com.team.prezel.core.model.terms.Term +import javax.inject.Inject + +class FetchTermsUseCase @Inject constructor( + private val termsRepository: TermsRepository, +) { + suspend operator fun invoke(): Result> = termsRepository.fetchTerms() +} diff --git a/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/GetUserNicknameUseCase.kt b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/GetUserNicknameUseCase.kt new file mode 100644 index 00000000..79ea1dfa --- /dev/null +++ b/Prezel/core/domain/src/main/kotlin/com/team/prezel/core/domain/usecase/user/GetUserNicknameUseCase.kt @@ -0,0 +1,13 @@ +package com.team.prezel.core.domain.usecase.user + +import com.team.prezel.core.domain.repository.profile.UserRepository +import javax.inject.Inject + +/** + * 캐시된 사용자 닉네임을 우선 조회하는 UseCase. + */ +class GetUserNicknameUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + suspend operator fun invoke(): Result = userRepository.getUserNickname() +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/BadgeSortType.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/BadgeSortType.kt new file mode 100644 index 00000000..ac4e57bf --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/badge/BadgeSortType.kt @@ -0,0 +1,8 @@ +package com.team.prezel.core.model.badge + +enum class BadgeSortType( + val value: String, +) { + DEFAULT(value = "DEFAULT"), + ACQUIRED(value = "ACQUIRED"), +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Curation.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Curation.kt new file mode 100644 index 00000000..e1e3ac1e --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Curation.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.model.presentation + +data class Curation( + val guideMessage: String, + val materialType: String, + val title: String, + val sourceChannel: String, + val linkUrl: String, + val imageUrl: String, +) 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 index 46e16217..f6b5d17a 100644 --- 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 @@ -11,4 +11,5 @@ data class MainDataWithPracticeRecords( val dDay: String, val growthGraph: List, val practiceRecords: PracticeRecords, + val curations: List, ) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/terms/Term.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/terms/Term.kt new file mode 100644 index 00000000..22150e8a --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/terms/Term.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.model.terms + +data class Term( + val termsId: Long, + val title: String, + val summary: String, + val content: String, + val isRequired: Boolean, + val version: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt index 6326040b..5bd4c517 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSource.kt @@ -6,7 +6,7 @@ import com.team.prezel.core.network.model.badge.GetBadgeResponse import kotlinx.coroutines.flow.Flow interface BadgeRemoteDataSource { - suspend fun getBadges(): List + suspend fun getBadges(sort: String): List suspend fun getBadgeDetail(badgeCode: String): GetBadgeDetailResponse diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt index a10e2621..f64e7a5a 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/BadgeRemoteDataSourceImpl.kt @@ -28,7 +28,7 @@ internal class BadgeRemoteDataSourceImpl @Inject constructor( private val badgeService: BadgeService, private val httpClient: HttpClient, ) : BadgeRemoteDataSource { - override suspend fun getBadges(): List = badgeService.getBadges().requireData() + override suspend fun getBadges(sort: String): List = badgeService.getBadges(sort = sort).requireData() override suspend fun getBadgeDetail(badgeCode: String): GetBadgeDetailResponse = badgeService.getBadgeDetail(badgeCode = badgeCode).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 3d5fb6e0..8ed9f78a 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,6 @@ package com.team.prezel.core.network.datasource +import com.team.prezel.core.network.model.presentation.GetCurationResponse 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 @@ -49,4 +50,6 @@ interface PresentationRemoteDataSource { presentationId: Long, content: String, ) + + suspend fun getCuration(presentationId: Long): 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 3fd9c211..4439631d 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,6 @@ package com.team.prezel.core.network.datasource +import com.team.prezel.core.network.model.presentation.GetCurationResponse 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 @@ -118,6 +119,9 @@ internal class PresentationRemoteDataSourceImpl @Inject constructor( request = SelfFeedbackRequest(content = content), ).requireSuccess() } + + override suspend fun getCuration(presentationId: Long): List = + presentationService.getCuration(presentationId = presentationId).requireData() } private fun FormBuilder.appendAudioPart(audioFilePath: String) { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSource.kt index c65aca49..b798fca6 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSource.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSource.kt @@ -1,7 +1,10 @@ package com.team.prezel.core.network.datasource import com.team.prezel.core.network.model.terms.AgreeTermsRequest +import com.team.prezel.core.network.model.terms.FetchTermsResponse interface TermsRemoteDataSource { + suspend fun fetchTerms(): List + suspend fun agreeTerms(request: List) } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSourceImpl.kt index 018db798..18b30772 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/TermsRemoteDataSourceImpl.kt @@ -1,13 +1,17 @@ package com.team.prezel.core.network.datasource +import com.team.prezel.core.network.model.requireData import com.team.prezel.core.network.model.requireSuccess import com.team.prezel.core.network.model.terms.AgreeTermsRequest +import com.team.prezel.core.network.model.terms.FetchTermsResponse import com.team.prezel.core.network.service.TermsService import javax.inject.Inject internal class TermsRemoteDataSourceImpl @Inject constructor( private val termsService: TermsService, ) : TermsRemoteDataSource { + override suspend fun fetchTerms(): List = termsService.fetchTerms().requireData() + override suspend fun agreeTerms(request: List) { termsService.agreeTerms(request = request).requireSuccess() } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/UserRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/UserRemoteDataSourceImpl.kt index 18c1ddd9..566118b5 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/UserRemoteDataSourceImpl.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/UserRemoteDataSourceImpl.kt @@ -32,7 +32,7 @@ internal class UserRemoteDataSourceImpl @Inject constructor( append(HttpHeaders.ContentDisposition, "filename=\"${file.name}\"") }, ) - } + } ?: append("\"deleteImage\"", true) }, ) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt new file mode 100644 index 00000000..23aa3371 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/presentation/GetCurationResponse.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.network.model.presentation + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetCurationResponse( + @SerialName("guideMessage") + val guideMessage: String, + @SerialName("imageUrl") + val imageUrl: String, + @SerialName("linkUrl") + val linkUrl: String, + @SerialName("materialType") + val materialType: String, + @SerialName("sourceChannel") + val sourceChannel: String, + @SerialName("title") + val title: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/terms/FetchTermsResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/terms/FetchTermsResponse.kt new file mode 100644 index 00000000..0f0e6ab1 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/terms/FetchTermsResponse.kt @@ -0,0 +1,20 @@ +package com.team.prezel.core.network.model.terms + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class FetchTermsResponse( + @SerialName("content") + val content: String, + @SerialName("isRequired") + val isRequired: Boolean, + @SerialName("summary") + val summary: String, + @SerialName("termsId") + val termsId: Int, + @SerialName("title") + val title: String, + @SerialName("version") + val version: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt index 4d3f5ef7..4f6efb9f 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/BadgeService.kt @@ -5,10 +5,13 @@ import com.team.prezel.core.network.model.badge.GetBadgeDetailResponse import com.team.prezel.core.network.model.badge.GetBadgeResponse import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query interface BadgeService { @GET("badges") - suspend fun getBadges(): BaseResponse> + suspend fun getBadges( + @Query("sort") sort: String, + ): BaseResponse> @GET("badges/{badgeCode}") suspend fun getBadgeDetail( 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 ddf1a13d..c18a4c40 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,7 @@ package com.team.prezel.core.network.service import com.team.prezel.core.network.model.BaseResponse +import com.team.prezel.core.network.model.presentation.GetCurationResponse 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 @@ -72,4 +73,9 @@ interface PresentationService { @Path("presentationId") presentationId: Long, @Body request: SelfFeedbackRequest, ): BaseResponse + + @GET("api/presentations/{presentationId}/curations") + suspend fun getCuration( + @Path("presentationId") presentationId: Long, + ): BaseResponse> } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/TermsService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/TermsService.kt index 4aea9f15..257fffb6 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/TermsService.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/TermsService.kt @@ -2,7 +2,9 @@ package com.team.prezel.core.network.service import com.team.prezel.core.network.model.BaseResponse import com.team.prezel.core.network.model.terms.AgreeTermsRequest +import com.team.prezel.core.network.model.terms.FetchTermsResponse import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET import de.jensklingenberg.ktorfit.http.POST interface TermsService { @@ -10,4 +12,7 @@ interface TermsService { suspend fun agreeTerms( @Body request: List, ): BaseResponse + + @GET("terms") + suspend fun fetchTerms(): BaseResponse> } diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt index ceb6a239..32bc8387 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/SpeedGraph.kt @@ -27,7 +27,7 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import kotlin.math.max import kotlin.math.min -private const val DEFAULT_LOWER_BOUND = 180 +private const val DEFAULT_LOWER_BOUND = 190 private const val DEFAULT_UPPER_BOUND = 280 private const val DEFAULT_GOOD_LOWER_BOUND = 210 private const val DEFAULT_GOOD_UPPER_BOUND = 260 diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt index 8d4658bf..03e6c437 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/graph/StickGraph.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text @@ -25,6 +26,7 @@ import kotlinx.collections.immutable.persistentMapOf private const val STICK_GRAPH_MAX_HEIGHT = 160 private val STICK_GRAPH_ITEM_WIDTH = 52.dp +private val STICK_GRAPH_TRACK_HEIGHT = STICK_GRAPH_MAX_HEIGHT.dp enum class StickGraphItemType { SPELLING, @@ -76,13 +78,22 @@ private fun StickGraphItem( ) Box( - modifier = modifier + modifier = Modifier .size( width = STICK_GRAPH_ITEM_WIDTH, - height = bar.height, + height = STICK_GRAPH_TRACK_HEIGHT, ).clip(PrezelTheme.shapes.V4) - .background(color = bar.color), - ) + .background(color = PrezelTheme.colors.bgMedium), + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .size(height = bar.height, width = STICK_GRAPH_ITEM_WIDTH) + .clip(PrezelTheme.shapes.V4) + .background(color = bar.color), + ) + } Text( text = bar.label, @@ -94,24 +105,24 @@ private fun StickGraphItem( @Composable private fun ImmutableMap.toStickGraphBars(): List { - val maxCount = values.maxOrNull() ?: 0 + val totalCount = values.sum() return StickGraphItemType.entries.map { itemType -> val count = getValue(itemType) StickGraphBar( count = count, - height = count.toStickHeight(maxCount = maxCount), + height = count.toStickHeight(totalCount = totalCount), color = itemType.itemColor(), label = itemType.itemLabel(), ) } } -private fun Int.toStickHeight(maxCount: Int): Dp { - if (maxCount <= 0) return 0.dp +private fun Int.toStickHeight(totalCount: Int): Dp { + if (totalCount <= 0) return 0.dp - val heightRatio = this.toFloat() / maxCount + val heightRatio = this.toFloat() / totalCount return (heightRatio * STICK_GRAPH_MAX_HEIGHT).dp } 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 index 53c61478..d6d928a3 100644 --- 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 @@ -1,5 +1,6 @@ package com.team.prezel.core.ui.state +import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -13,16 +14,24 @@ class AppDimmerState internal constructor() { var isVisible by mutableStateOf(false) private set + var foregroundContent by mutableStateOf<(@Composable BoxScope.() -> Unit)?>(null) + private set + private var onDismissRequest: (() -> Unit)? by mutableStateOf(null) - fun show(onDismissRequest: () -> Unit = {}) { + fun show( + onDismissRequest: () -> Unit = {}, + foregroundContent: (@Composable BoxScope.() -> Unit)? = null, + ) { this.onDismissRequest = onDismissRequest + this.foregroundContent = foregroundContent isVisible = true } fun hide() { isVisible = false onDismissRequest = null + foregroundContent = null } fun dismiss() { diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/state/LocalSnackbarCoroutineScope.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/state/LocalSnackbarCoroutineScope.kt new file mode 100644 index 00000000..660b910e --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/state/LocalSnackbarCoroutineScope.kt @@ -0,0 +1,8 @@ +package com.team.prezel.core.ui.state + +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.CoroutineScope + +val LocalSnackbarCoroutineScope = staticCompositionLocalOf { + error("SnackbarCoroutineScope is not provided") +} diff --git a/Prezel/detekt-config.yml b/Prezel/detekt-config.yml index 9c9dbffc..a4a61198 100644 --- a/Prezel/detekt-config.yml +++ b/Prezel/detekt-config.yml @@ -20,7 +20,7 @@ style: complexity: LongMethod: - threshold: 50 + threshold: 70 ignoreAnnotated: - Preview - BasicPreview @@ -44,6 +44,8 @@ complexity: - Preview - BasicPreview - LargeDevicePreview + ComplexCondition: + active: false naming: FunctionNaming: diff --git a/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt index b748b6b5..af52ae60 100644 --- a/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt +++ b/Prezel/feature/analysis/api/src/main/java/com/team/prezel/feature/analysis/api/AnalysisNavKey.kt @@ -1,10 +1,12 @@ package com.team.prezel.feature.analysis.api +import androidx.compose.runtime.Immutable import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable import java.util.UUID @Serializable +@Immutable sealed interface AnalysisNavKey : NavKey { val flowId: String val startType: AnalysisStartType diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt index aab393b0..23e88f4e 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisFlowViewModel.kt @@ -18,26 +18,32 @@ import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowUiState import com.team.prezel.feature.analysis.impl.contract.AnalysisUploadType import com.team.prezel.feature.analysis.impl.contract.ScriptInputType import com.team.prezel.feature.analysis.impl.model.AnalysisUiMessage +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import javax.inject.Inject -private const val ANALYSIS_TIMEOUT_MILLIS = 15_000L private val SUPPORTED_AUDIO_FILE_EXTENSIONS = setOf("m4a", "mp4", "mp3") -@HiltViewModel -internal class AnalysisFlowViewModel @Inject constructor( +@HiltViewModel(assistedFactory = AnalysisFlowViewModel.Factory::class) +internal class AnalysisFlowViewModel @AssistedInject constructor( + @Assisted isFromReport: Boolean, private val analyzePresentationUseCase: AnalyzePresentationUseCase, private val reAnalyzePresentationUseCase: ReAnalyzePresentationUseCase, private val fetchPresentationDetailUseCase: FetchPresentationDetailUseCase, private val fetchPresentationScriptDetailUseCase: FetchPresentationScriptDetailUseCase, private val analysisFileCache: AnalysisFileCache, private val audioController: RecordingAudioController, -) : BaseViewModel(AnalysisFlowUiState()) { +) : BaseViewModel(AnalysisFlowUiState(isFromReport = isFromReport)) { + @AssistedFactory + interface Factory { + fun create(isFromReport: Boolean): AnalysisFlowViewModel + } + private var analyzeJob: Job? = null init { @@ -74,6 +80,7 @@ internal class AnalysisFlowViewModel @Inject constructor( startType = intent.startType, ) } + is AnalysisFlowUiIntent.StartReRecording -> startReRecording( presentationId = intent.presentationId, isPast = intent.isPast, @@ -89,6 +96,7 @@ internal class AnalysisFlowViewModel @Inject constructor( fileUri = intent.fileUri, fileName = intent.fileName, ) + is AnalysisFlowUiIntent.RetryFileUpload -> retryFileUpload(intent.uploadType) AnalysisFlowUiIntent.Next -> moveNext() AnalysisFlowUiIntent.SkipScript -> skipScript() @@ -401,9 +409,7 @@ internal class AnalysisFlowViewModel @Inject constructor( } currentState.backClearedFormOrNull()?.let { clearedForm -> - updateState { - copy(form = clearedForm) - } + updateState { copy(form = clearedForm) } } viewModelScope.launch { sendEffect(AnalysisFlowUiEffect.NavigateBack) } @@ -441,48 +447,39 @@ private suspend fun PresentationAnalysisSummary.fetchOriginalScript( .map { it.originalScript } } -private suspend fun runAnalysisCatching(block: suspend () -> T): Result = - try { - Result.success(withTimeout(ANALYSIS_TIMEOUT_MILLIS) { block() }) - } catch (throwable: Throwable) { - Result.failure(throwable) - } - private suspend fun PresentationAnalysisSubmission.analyzePresentationRecording( analysisFileCache: AnalysisFileCache, analyzePresentationUseCase: AnalyzePresentationUseCase, -): Result = - runAnalysisCatching { - val audioFilePath = resolveAudioFilePath(analysisFileCache) - val scriptFilePath = resolveScriptFilePath(analysisFileCache) - analyzePresentationUseCase( - name = name, - date = date.toRequestDate(), - category = category, - purpose = purpose, - style = style, - audience = audience, - script = script, - scriptFilePath = scriptFilePath, - audioFilePath = audioFilePath, - ).getOrThrow() - } +): Result { + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) + return analyzePresentationUseCase( + name = name, + date = date.toRequestDate(), + category = category, + purpose = purpose, + style = style, + audience = audience, + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ) +} private suspend fun PresentationAnalysisSubmission.reAnalyzePresentationRecording( presentationId: Long, analysisFileCache: AnalysisFileCache, reAnalyzePresentationUseCase: ReAnalyzePresentationUseCase, -): Result = - runAnalysisCatching { - val audioFilePath = resolveAudioFilePath(analysisFileCache) - val scriptFilePath = resolveScriptFilePath(analysisFileCache) - reAnalyzePresentationUseCase( - presentationId = presentationId, - script = script, - scriptFilePath = scriptFilePath, - audioFilePath = audioFilePath, - ).getOrThrow() - } +): Result { + val audioFilePath = resolveAudioFilePath(analysisFileCache) + val scriptFilePath = resolveScriptFilePath(analysisFileCache) + return reAnalyzePresentationUseCase( + presentationId = presentationId, + script = script, + scriptFilePath = scriptFilePath, + audioFilePath = audioFilePath, + ) +} private fun PresentationAnalysisSubmission.resolveAudioFilePath(analysisFileCache: AnalysisFileCache): String = audioFileUri @@ -507,8 +504,7 @@ private fun PresentationAnalysisSubmission.resolveScriptFilePath(analysisFileCac private fun String?.isSupportedAudioFileName(): Boolean { if (this == null) return true - val extension = substringAfterLast('.', missingDelimiterValue = "") - .lowercase() + val extension = substringAfterLast('.', missingDelimiterValue = "").lowercase() return extension in SUPPORTED_AUDIO_FILE_EXTENSIONS } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index c97a9fea..da066b0b 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -16,6 +16,7 @@ import com.team.prezel.core.common.event.EdgeToEdgeStatusBarStyle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.component.voice.VoiceChromeGradient import com.team.prezel.core.designsystem.component.voice.VoiceChromeStatus +import com.team.prezel.core.ui.state.LocalSnackbarCoroutineScope import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.analysis.impl.audio.AudioUploadScreen import com.team.prezel.feature.analysis.impl.contract.AnalysisFlowStep @@ -55,6 +56,7 @@ internal fun AnalysisScreen( val uiState = viewModel.uiState.collectAsStateWithLifecycle().value val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current + val scope = LocalSnackbarCoroutineScope.current var isScriptExpanded by rememberSaveable(uiState.step) { mutableStateOf(false) } BackHandler { @@ -66,7 +68,15 @@ internal fun AnalysisScreen( when (effect) { AnalysisFlowUiEffect.NavigateBack -> onBack() is AnalysisFlowUiEffect.NavigateToStep -> navigateToStep(effect.step, effect.clearStack) - is AnalysisFlowUiEffect.NavigateToReport -> navigateToReport(effect.presentationId) + is AnalysisFlowUiEffect.NavigateToReport -> { + navigateToReport(effect.presentationId) + scope.launch { + snackbarHostState.showPrezelSnackbar( + message = resources.getString(R.string.feature_analysis_impl_analyze_complete_message), + useRaisedPosition = false, + ) + } + } is AnalysisFlowUiEffect.ShowMessage -> { snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.toStringRes())) } diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt index 64f49fcc..0293855a 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/component/AnalysisStepLayout.kt @@ -52,6 +52,7 @@ internal fun AnalysisStepLayout( subButtonText: String? = null, onSubButtonClick: (() -> Unit)? = null, contentScrollable: Boolean = true, + isHiddenOptions: Boolean = false, content: @Composable ColumnScope.() -> Unit, ) { val contentScrollState = rememberScrollState() @@ -67,6 +68,7 @@ internal fun AnalysisStepLayout( PrezelTopAppBar( title = { Text(text = title) }, leadingIcon = { + if (isHiddenOptions) return@PrezelTopAppBar IconButton(onClick = onBack) { Icon( painter = painterResource(PrezelIcons.ArrowLeft), @@ -75,6 +77,15 @@ internal fun AnalysisStepLayout( } }, trailingIcons = { + if (isHiddenOptions) { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(PrezelIcons.Cancel), + contentDescription = null, + ) + } + return@PrezelTopAppBar + } AnalysisStepTrailingText( text = trailingText, onClick = onTrailingTextClick, diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt index db63a0e5..bd0edcb9 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/contract/AnalysisFlowUiState.kt @@ -14,6 +14,7 @@ import kotlinx.collections.immutable.persistentListOf @Immutable internal data class AnalysisFlowUiState( val step: AnalysisFlowStep = AnalysisFlowStep.PRESENTATION_SCHEDULE, + val isFromReport: Boolean = false, val form: AnalysisForm = AnalysisForm(), val recordingState: AudioSessionState = AudioSessionState.Idle, val recordingVolumes: ImmutableList = persistentListOf(), @@ -59,6 +60,8 @@ internal data class AnalysisFlowUiState( AnalysisFlowStep.SCRIPT_FILE_RECOGNITION_FAILED, -> false } + + val isScriptInputOptionsHidden: Boolean = isFromReport && step == AnalysisFlowStep.SCRIPT_INPUT } @Immutable diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt index 0968d33c..caf334e4 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -52,7 +52,7 @@ private fun AnalysisNavKey.enterStep(step: AnalysisFlowStep): AnalysisFlowUiInte private inline fun EntryProviderScope.analysisEntry(crossinline enterIntent: (T) -> AnalysisFlowUiIntent) { entry { key -> AnalysisRoute( - flowId = key.flowId, + navKey = key, enterIntent = enterIntent(key), stepToNavKey = key::toNavKey, ) @@ -61,15 +61,16 @@ private inline fun EntryProviderScope.analy @Composable private fun AnalysisRoute( - flowId: String, + navKey: AnalysisNavKey, enterIntent: AnalysisFlowUiIntent, stepToNavKey: (AnalysisFlowStep) -> AnalysisNavKey, ) { val navigator = LocalNavigator.current val viewModelStoreOwner = LocalContext.current.findViewModelStoreOwner() - val viewModel = hiltViewModel( + val viewModel = hiltViewModel( viewModelStoreOwner = viewModelStoreOwner, - key = flowId, + key = navKey.flowId, + creationCallback = { factory -> factory.create(navKey is AnalysisNavKey.ReWritingScript) }, ) LaunchedEffect(enterIntent) { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt index 8347e3f1..56754257 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/script/ScriptInputScreen.kt @@ -98,6 +98,7 @@ internal fun ScriptInputScreen( uploadProgress = uploadProgress, progress = uiState.progress, buttonEnabled = uiState.canMoveNext, + isHiddenOptions = uiState.isScriptInputOptionsHidden, onSelectInputType = onSelectInputType, onClearPendingScriptFile = { pendingScriptFileUri = null }, onScriptChange = onScriptChange, @@ -122,6 +123,7 @@ private fun ScriptInputScreen( uploadProgress: Float, progress: Float, buttonEnabled: Boolean, + isHiddenOptions: Boolean, onSelectInputType: (ScriptInputType) -> Unit, onClearPendingScriptFile: () -> Unit, onScriptChange: (String) -> Unit, @@ -152,6 +154,7 @@ private fun ScriptInputScreen( buttonEnabled = buttonEnabled, onButtonClick = onNext, onBack = onBack, + isHiddenOptions = isHiddenOptions, trailingText = stringResource(R.string.feature_analysis_impl_skip), onTrailingTextClick = onSkip, contentScrollable = false, @@ -423,6 +426,7 @@ private fun ScriptInputUploadProgressScreenPreview() { uploadProgress = 0.5f, progress = AnalysisFlowUiState(step = AnalysisFlowStep.SCRIPT_INPUT).progress, buttonEnabled = false, + isHiddenOptions = false, onSelectInputType = {}, onClearPendingScriptFile = {}, onScriptChange = {}, diff --git a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml index 0e55a190..e6fbc2da 100644 --- a/Prezel/feature/analysis/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/analysis/impl/src/main/res/values/strings.xml @@ -100,4 +100,5 @@ 건너뛰기 다시 시도하기 분석하기 + 분석 리포트가 저장되었어요. diff --git a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt index 0eab9e17..6decec20 100644 --- a/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt +++ b/Prezel/feature/badge/impl/src/main/java/com/team/prezel/feature/badge/impl/component/BadgeDetailModal.kt @@ -63,9 +63,7 @@ internal fun BadgeDetailModal( Spacer(modifier = Modifier.weight(72f)) Column( - modifier = Modifier - .fillMaxWidth() - .weight(376f), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { BadgeHeader( diff --git a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt index 11a40dad..ee2532e2 100644 --- a/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt +++ b/Prezel/feature/history/impl/src/main/java/com/team/prezel/feature/history/impl/navigation/HistoryEntryBuilder.kt @@ -23,7 +23,6 @@ internal fun EntryProviderScope.featureHistoryEntryBuilder() { ReportNavKey( presentationId = presentationId, isPast = isPast, - showBackButton = true, ), ) }, 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 30dd9b66..e2c2f5af 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 @@ -26,6 +26,7 @@ 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 kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.datetime.LocalDate @@ -132,6 +133,7 @@ private fun HomeScreenMultiplePreview() { startDate = LocalDate(2026, 4, 7 + index), endDate = LocalDate(2026, 4, 10 + index), ), + curations = persistentListOf(), ) }.toPersistentList(), ) 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 index bd3d3604..a6f8561e 100644 --- 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 @@ -1,10 +1,7 @@ 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.foundation.layout.absoluteOffset import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -12,31 +9,19 @@ 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.Density 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 @@ -46,15 +31,25 @@ internal fun HomeAnalysisFabOverlay( 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) } + var fabBounds by remember { mutableStateOf(null) } - LaunchedEffect(isFabExpanded) { + LaunchedEffect(isFabExpanded, fabBounds, appDimmerState) { if (isFabExpanded) { - appDimmerState.show { isFabExpanded = false } + val anchorBounds = fabBounds + if (anchorBounds != null) { + appDimmerState.show( + onDismissRequest = { isFabExpanded = false }, + foregroundContent = { + HomeAnalysisFabForeground( + anchorBounds = anchorBounds, + onCollapse = { isFabExpanded = false }, + onClickVoiceRecordingAnalysis = onClickVoiceRecordingAnalysis, + onClickFileUploadAnalysis = onClickFileUploadAnalysis, + ) + }, + ) + } } else { appDimmerState.hide() } @@ -64,166 +59,67 @@ internal fun HomeAnalysisFabOverlay( 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( + PrezelFloatingMenu( isExpanded = false, - modifier = Modifier.align(Alignment.BottomEnd), - onFabPositioned = onCollapsedFabPositioned, - ) - HomeAnalysisFabMeasurementAnchor( - isExpanded = true, - modifier = Modifier.align(Alignment.BottomEnd), - onFabPositioned = onExpandedMenuPositioned, - ) + onChangeExpanded = { isFabExpanded = it }, + iconResId = PrezelIcons.Plus, + openIconResId = PrezelIcons.Cancel, + size = ButtonSize.REGULAR, + hierarchy = ButtonHierarchy.PRIMARY, + modifier = modifier.onGloballyPositioned { coordinates -> + fabBounds = coordinates.boundsInWindow() + }, + ) { } } @Composable -private fun HomeAnalysisFabPopup( - collapsedFabBounds: Rect?, - expandedMenuBounds: Rect?, - fabEndPaddingPx: Int, - density: Density, - isFabExpanded: Boolean, - onChangeExpanded: (Boolean) -> Unit, +private fun BoxScope.HomeAnalysisFabForeground( + anchorBounds: Rect, + onCollapse: () -> 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, - ) -} + var menuSize by remember { mutableStateOf(IntSize.Zero) } -@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, + isExpanded = true, + onChangeExpanded = { expanded -> + if (!expanded) onCollapse() + }, 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) - }, - ), + modifier = Modifier + .onGloballyPositioned { coordinates -> menuSize = coordinates.size } + .then( + if (menuSize == IntSize.Zero) { + Modifier + } else { + Modifier.absoluteOffset { + IntOffset( + x = (anchorBounds.right - menuSize.width).roundToInt(), + y = (anchorBounds.bottom - menuSize.height).roundToInt(), + ) + } + }, + ), ) { MenuItem( label = stringResource(R.string.feature_home_impl_analysis_voice_recording), iconResId = PrezelIcons.Mic, - onClick = onClickVoiceRecording, + onClick = { + onCollapse() + onClickVoiceRecordingAnalysis() + }, ) 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), + onClick = { + onCollapse() + onClickFileUploadAnalysis() + }, ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt index 03c266b8..c8773de4 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/main/component/HomeScreenContent.kt @@ -2,10 +2,14 @@ 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.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets 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.material3.FabPosition +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -15,6 +19,7 @@ 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.designsystem.theme.PrezelTheme 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 @@ -45,30 +50,43 @@ internal fun HomeScreenContent( 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, - ) + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = PrezelTheme.colors.bgRegular, + contentWindowInsets = WindowInsets(0, 0, 0, 0), + topBar = { + HomeHeadSection( + uiState = uiState, + pagerState = pagerState, + onClickTab = { pageIndex -> scope.launch { pagerState.scrollToPage(pageIndex) } }, + modifier = Modifier.onHeightChanged { newHeight -> headerHeight = newHeight }, + ) + }, + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + HomeAnalysisFabOverlay( + onClickVoiceRecordingAnalysis = onClickVoiceRecordingAnalysis, + onClickFileUploadAnalysis = onClickFileUploadAnalysis, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .consumeWindowInsets(innerPadding), + ) { + HomeContent( + uiState = uiState, + pagerState = pagerState, + maxHeight = maxScreenHeight, + headerHeight = headerHeight, + onClickAddPresentation = onClickAddPresentation, + onClickPracticeRecording = onClickPracticeRecording, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + onClickCardGraphItemIndex = onClickCardGraphItemIndex, + ) + } } } } 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 43358c53..3d03f78e 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 @@ -17,6 +17,7 @@ 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.collections.immutable.persistentListOf import kotlinx.datetime.LocalDate @Composable @@ -80,6 +81,7 @@ private fun PresentationContentPreview() { startDate = LocalDate(2026, 9, 26), endDate = LocalDate(2026, 10, 1), ), + curations = persistentListOf(), ) Box(modifier = Modifier.padding(top = 16.dp)) { 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 8a88d0c4..07699fc1 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 @@ -21,6 +21,7 @@ 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.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.LocalDate @@ -67,6 +68,7 @@ private fun HomeHeadSectionSinglePreview() { startDate = LocalDate(2023, 12, 28), endDate = LocalDate(2024, 1, 1), ), + curations = persistentListOf(), ), ) val pagerState = rememberPagerState(0) { uiState.presentationCount() } @@ -96,6 +98,7 @@ private fun HomeHeadSectionMultiplePreview() { startDate = LocalDate(2023, 12, 28), endDate = LocalDate(2024, 1, 1), ), + curations = persistentListOf(), ), PresentationUiModel.Past( id = 2L, 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 922bed9c..28e4f991 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 @@ -20,6 +20,7 @@ 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.collections.immutable.persistentListOf import kotlinx.datetime.LocalDate import kotlinx.datetime.number @@ -151,6 +152,7 @@ private fun HomePresentationPagePreview() { startDate = LocalDate(2026, 9, 26), endDate = LocalDate(2026, 10, 1), ), + curations = persistentListOf(), ), onClickAnalyzePresentation = {}, onClickWriteFeedback = {}, 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 c540703c..25826b51 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,10 +2,13 @@ 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.Curation 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.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.datetime.LocalDate @Immutable @@ -38,6 +41,7 @@ internal sealed interface PresentationUiModel { override val date: LocalDate, override val dDay: String, override val practiceRecords: PracticeRecordsUiModel, + val curations: ImmutableList, ) : PresentationUiModel companion object { @@ -60,6 +64,7 @@ internal sealed interface PresentationUiModel { date = presentationDate, dDay = dDay, practiceRecords = practiceRecords.toUiModel(), + curations = curations.toImmutableList(), ) } } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt index 5f66af12..28ccc83a 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt @@ -1,24 +1,36 @@ package com.team.prezel.feature.my.impl import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.painterResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.component.list.PrezelList +import com.team.prezel.core.designsystem.component.list.PrezelListSize +import com.team.prezel.core.designsystem.icon.PrezelIcons import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.component.PrezelBadge import com.team.prezel.core.ui.state.LocalSnackbarHostState -import com.team.prezel.feature.my.impl.component.BadgeSection +import com.team.prezel.core.ui.util.noRippleClickable import com.team.prezel.feature.my.impl.component.MyTopAppBar import com.team.prezel.feature.my.impl.component.ProfileSection import com.team.prezel.feature.my.impl.contract.MyUiEffect @@ -28,7 +40,6 @@ import com.team.prezel.feature.my.impl.model.BadgeUiModel import com.team.prezel.feature.my.impl.model.MyUiMessage import kotlinx.collections.immutable.toImmutableList -@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MyScreen( navigateToEditProfile: () -> Unit, @@ -76,28 +87,88 @@ private fun MyScreen( modifier: Modifier = Modifier, ) { Column( - modifier = modifier.fillMaxSize().background(PrezelTheme.colors.bgRegular), + modifier = modifier + .fillMaxSize() + .background(PrezelTheme.colors.bgRegular), horizontalAlignment = Alignment.CenterHorizontally, ) { MyTopAppBar(onClickSetting = onClickSetting) - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + vertical = PrezelTheme.spacing.V16, + horizontal = PrezelTheme.spacing.V20, + ), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + horizontalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V16), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + ProfileSection( + nickname = uiState.nickname, + profileImageUrl = uiState.profileImageUrl, + onClickEditProfile = onClickEditProfile, + modifier = Modifier.fillMaxWidth(), + ) + } - ProfileSection( - nickname = uiState.nickname, - profileImageUrl = uiState.profileImageUrl, - onClickEditProfile = onClickEditProfile, - ) + item(span = { GridItemSpan(maxLineSpan) }) { + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) + } - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V32)) + item(span = { GridItemSpan(maxLineSpan) }) { + BadgeListTitle( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable(onClick = navigateToBadge), + ) + } - BadgeSection( - badges = uiState.badges, - onClickBadge = navigateToBadge, - ) + items( + items = uiState.badges, + key = { badge -> badge.code }, + ) { badge -> + BadgeGridItem( + badge = badge, + modifier = modifier.noRippleClickable(onClick = navigateToBadge), + ) + } + } } } +@Composable +private fun BadgeListTitle(modifier: Modifier = Modifier) { + PrezelList( + title = "나의 뱃지", + titleTextColor = PrezelTheme.colors.textLarge, + size = PrezelListSize.REGULAR, + nested = true, + modifier = modifier.fillMaxWidth(), + trailingContent = { + Icon( + painter = painterResource(PrezelIcons.ChevronRight), + contentDescription = "뱃지", + tint = PrezelTheme.colors.iconRegular, + ) + }, + ) +} + +@Composable +private fun BadgeGridItem( + badge: BadgeUiModel, + modifier: Modifier = Modifier, +) { + PrezelBadge( + title = badge.title, + url = badge.imageUrl, + isAchieved = badge.isAchieved, + modifier = modifier.fillMaxWidth(), + ) +} + @BasicPreview @Composable private fun MyScreenPreview() { diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt index e57b6aea..a6835bce 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -3,6 +3,7 @@ package com.team.prezel.feature.my.impl import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.usecase.badge.FetchBadgesUseCase import com.team.prezel.core.domain.usecase.user.FetchUserInfoUseCase +import com.team.prezel.core.model.badge.BadgeSortType import com.team.prezel.core.model.profile.User import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.my.impl.contract.MyUiEffect @@ -42,7 +43,7 @@ internal class MyViewModel @Inject constructor( updateState { copy( isLoading = false, - profileImageUrl = user?.profileImageUrl ?: this.profileImageUrl, + profileImageUrl = user?.profileImageUrl, nickname = user?.nickname ?: this.nickname, badges = badges ?: this.badges, ) @@ -61,7 +62,7 @@ internal class MyViewModel @Inject constructor( ) private suspend fun fetchMyBadges(): ImmutableList? = - fetchBadgesUseCase().fold( + fetchBadgesUseCase(sort = BadgeSortType.ACQUIRED).fold( onSuccess = { badges -> badges .map { badge -> diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/BadgeSection.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/BadgeSection.kt deleted file mode 100644 index 36d23292..00000000 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/component/BadgeSection.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.team.prezel.feature.my.impl.component - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints -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.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import com.team.prezel.core.designsystem.component.list.PrezelList -import com.team.prezel.core.designsystem.component.list.PrezelListSize -import com.team.prezel.core.designsystem.icon.PrezelIcons -import com.team.prezel.core.designsystem.preview.BasicPreview -import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.ui.component.PrezelBadge -import com.team.prezel.feature.my.impl.model.BadgeUiModel -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList - -@Composable -internal fun BadgeSection( - badges: ImmutableList, - onClickBadge: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier - .fillMaxWidth() - .clickable( - onClick = onClickBadge, - interactionSource = null, - indication = null, - ), - ) { - BadgeListTitle() - - Spacer(modifier = Modifier.height(PrezelTheme.spacing.V16)) - - BadgeList(badges = badges) - } -} - -@Composable -private fun BadgeListTitle(modifier: Modifier = Modifier) { - PrezelList( - title = "나의 뱃지", - titleTextColor = PrezelTheme.colors.textLarge, - size = PrezelListSize.REGULAR, - nested = true, - modifier = modifier.padding(horizontal = PrezelTheme.spacing.V20), - trailingContent = { - Icon( - painter = painterResource(PrezelIcons.ChevronRight), - contentDescription = "뱃지", - tint = PrezelTheme.colors.iconRegular, - ) - }, - ) -} - -@Composable -private fun BadgeList( - badges: ImmutableList, - modifier: Modifier = Modifier, -) { - val horizontalPadding = PrezelTheme.spacing.V20 - val itemSpacing = PrezelTheme.spacing.V16 - - BoxWithConstraints(modifier = modifier.fillMaxWidth()) { - val itemSize = remember(maxWidth, horizontalPadding, itemSpacing) { - (maxWidth - horizontalPadding * 2 - itemSpacing) / 2 - } - - LazyRow( - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = horizontalPadding), - horizontalArrangement = Arrangement.spacedBy(itemSpacing), - overscrollEffect = null, - ) { - items(items = badges, key = { badge -> badge.code }) { badge -> - PrezelBadge( - title = badge.title, - url = badge.imageUrl, - isAchieved = badge.isAchieved, - modifier = Modifier.width(itemSize), - ) - } - } - } -} - -@BasicPreview -@Composable -private fun BadgeSectionPreview() { - PrezelTheme { - BadgeSection( - badges = List(6) { index -> - BadgeUiModel( - code = index.toString(), - title = index.toString(), - imageUrl = "", - isAchieved = index % 2 == 0, - ) - }.toImmutableList(), - onClickBadge = {}, - ) - } -} 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 e0fc6b68..5f813828 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 @@ -32,6 +32,7 @@ import com.team.prezel.core.designsystem.component.actions.button.config.ButtonT import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.state.LocalSnackbarCoroutineScope import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.core.ui.util.advancedImePadding import com.team.prezel.feature.profile.impl.component.NicknameTextField @@ -42,6 +43,8 @@ import com.team.prezel.feature.profile.impl.contract.ProfileUiIntent import com.team.prezel.feature.profile.impl.contract.ProfileUiState import com.team.prezel.feature.profile.impl.model.NicknameValidationState import com.team.prezel.feature.profile.impl.model.ProfileUiMessage +import com.team.prezel.feature.profile.impl.model.ProfileUpdateResult +import kotlinx.coroutines.launch import java.io.File @Composable @@ -54,6 +57,7 @@ internal fun ProfileScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = LocalSnackbarHostState.current + val snackbarCoroutineScope = LocalSnackbarCoroutineScope.current val resources = LocalResources.current val photoPickerLauncher = rememberProfileImagePicker { profileUrl, profileImageFile -> viewModel.onIntent( @@ -65,7 +69,19 @@ internal fun ProfileScreen( viewModel.uiEffect.collect { effect -> when (effect) { ProfileUiEffect.NavigateToHome -> navigateToHome() - ProfileUiEffect.NavigateToBack -> onBack() + is ProfileUiEffect.NavigateToBack -> { + effect.result?.let { result -> + snackbarCoroutineScope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showPrezelSnackbar( + message = resources.getString(result.messageResId), + useRaisedPosition = false, + ) + } + } + onBack() + } + is ProfileUiEffect.ShowMessage -> { snackbarHostState.showPrezelSnackbar( message = resources.getString(effect.message.resId), @@ -228,6 +244,13 @@ private val ProfileUiMessage.resId: Int ProfileUiMessage.PATCH_USER_PROFILE_FAILED -> R.string.feature_profile_impl_patch_user_profile_failed_message } +private val ProfileUpdateResult.messageResId: Int + get() = when (this) { + ProfileUpdateResult.Image -> R.string.feature_profile_impl_patch_user_profile_image_success_message + ProfileUpdateResult.Nickname -> R.string.feature_profile_impl_patch_user_nickname_success_message + ProfileUpdateResult.Profile -> R.string.feature_profile_impl_patch_user_profile_success_message + } + @BasicPreview @Composable private fun CreateProfileScreenPreview() { 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 fad99645..0c1ac15e 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 @@ -2,6 +2,7 @@ package com.team.prezel.feature.profile.impl import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.usecase.user.FetchUserInfoUseCase +import com.team.prezel.core.domain.usecase.user.GetUserNicknameUseCase import com.team.prezel.core.domain.usecase.user.PatchUserProfileUseCase import com.team.prezel.core.domain.usecase.user.ValidateNicknameUseCase import com.team.prezel.core.model.profile.Nickname @@ -9,9 +10,11 @@ import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.profile.impl.contract.ProfileUiEffect import com.team.prezel.feature.profile.impl.contract.ProfileUiIntent import com.team.prezel.feature.profile.impl.contract.ProfileUiState +import com.team.prezel.feature.profile.impl.contract.ProfileUiState.Content.Companion.fromCachedNickname import com.team.prezel.feature.profile.impl.contract.ProfileUiState.Content.Companion.toUiState import com.team.prezel.feature.profile.impl.model.NicknameValidationState import com.team.prezel.feature.profile.impl.model.ProfileUiMessage +import com.team.prezel.feature.profile.impl.model.ProfileUpdateResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow @@ -28,6 +31,7 @@ import javax.inject.Inject @HiltViewModel internal class ProfileViewModel @Inject constructor( private val fetchUserInfoUseCase: FetchUserInfoUseCase, + private val getUserNicknameUseCase: GetUserNicknameUseCase, private val patchUserProfileUseCase: PatchUserProfileUseCase, private val validateNicknameUseCase: ValidateNicknameUseCase, ) : BaseViewModel(ProfileUiState.Loading) { @@ -42,7 +46,10 @@ internal class ProfileViewModel @Inject constructor( .collectLatest(::validateNickname) } - fetchUserInfo() + viewModelScope.launch { + prefillNickname() + fetchUserInfo() + } } override fun onIntent(intent: ProfileUiIntent) { @@ -62,15 +69,23 @@ internal class ProfileViewModel @Inject constructor( } } - private fun fetchUserInfo() { - viewModelScope.launch { - fetchUserInfoUseCase() - .onSuccess { user -> updateState { user.toUiState() } } - .onFailure { throwable -> - sendEffect(ProfileUiEffect.ShowMessage(ProfileUiMessage.FETCH_USER_INFO_FAILED)) - Timber.e(throwable) - } - } + private suspend fun fetchUserInfo() { + fetchUserInfoUseCase() + .onSuccess { user -> updateState { user.toUiState() } } + .onFailure { throwable -> + sendEffect(ProfileUiEffect.ShowMessage(ProfileUiMessage.FETCH_USER_INFO_FAILED)) + Timber.e(throwable) + } + } + + private suspend fun prefillNickname() { + getUserNicknameUseCase() + .onSuccess { nickname -> + if (nickname.isBlank()) return@onSuccess + updateState { fromCachedNickname(nickname = nickname) } + }.onFailure { throwable -> + Timber.e(throwable) + } } private fun handleNicknameChanged(nickname: String) { @@ -110,7 +125,13 @@ internal class ProfileViewModel @Inject constructor( nickname = uiState.editing.nickname, profileImageFile = uiState.editing.profileImageFile, ).onSuccess { - if (uiState.isRegistered) return@launch sendEffect(ProfileUiEffect.NavigateToBack) + if (uiState.isRegistered) { + return@launch sendEffect( + ProfileUiEffect.NavigateToBack( + result = uiState.toProfileUpdateResult(), + ), + ) + } sendEffect(ProfileUiEffect.NavigateToHome) }.onFailure { throwable -> Timber.e(throwable) @@ -173,6 +194,13 @@ internal class ProfileViewModel @Inject constructor( private fun ProfileUiState.Content.updateNicknameValidation(validationState: NicknameValidationState): ProfileUiState.Content = copy(editing = editing.copy(nicknameValidation = validationState)) + private fun ProfileUiState.Content.toProfileUpdateResult(): ProfileUpdateResult = + when { + isProfileImageChanged && isNicknameChanged -> ProfileUpdateResult.Profile + isProfileImageChanged -> ProfileUpdateResult.Image + else -> ProfileUpdateResult.Nickname + } + private companion object { const val NICKNAME_VALIDATION_DEBOUNCE_MILLIS = 300L } diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt index be38e2bc..0594fb6d 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiEffect.kt @@ -2,11 +2,14 @@ package com.team.prezel.feature.profile.impl.contract import com.team.prezel.core.ui.base.UiEffect import com.team.prezel.feature.profile.impl.model.ProfileUiMessage +import com.team.prezel.feature.profile.impl.model.ProfileUpdateResult internal sealed interface ProfileUiEffect : UiEffect { data object NavigateToHome : ProfileUiEffect - data object NavigateToBack : ProfileUiEffect + data class NavigateToBack( + val result: ProfileUpdateResult? = null, + ) : ProfileUiEffect data class ShowMessage( val message: ProfileUiMessage, diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt index 3335436d..615f52a9 100644 --- a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/contract/ProfileUiState.kt @@ -28,6 +28,7 @@ internal sealed interface ProfileUiState : UiState { data class Content( val isRegistered: Boolean, + val isHydrated: Boolean = true, val original: OriginalProfile, val editing: EditingProfile, ) : ProfileUiState { @@ -35,13 +36,37 @@ internal sealed interface ProfileUiState : UiState { val isProfileImageChanged: Boolean = editing.profileImageUrl != original.profileImageUrl - val submitButtonEnabled: Boolean = - isProfileImageChanged || (isNicknameChanged && editing.nicknameValidation == NicknameValidationState.Available) + val hasAnyChanges: Boolean = isProfileImageChanged || isNicknameChanged + + val isNicknameSubmittable: Boolean = + !isNicknameChanged || editing.nicknameValidation == NicknameValidationState.Available + + val submitButtonEnabled: Boolean = isHydrated && hasAnyChanges && isNicknameSubmittable companion object { + fun fromCachedNickname( + nickname: String, + isRegistered: Boolean = true, + ): ProfileUiState = + Content( + isRegistered = isRegistered, + isHydrated = false, + original = OriginalProfile( + nickname = nickname, + profileImageUrl = null, + ), + editing = EditingProfile( + nickname = nickname, + nicknameValidation = NicknameValidationState.Unchecked, + profileImageUrl = null, + profileImageFile = null, + ), + ) + fun User.toUiState(): ProfileUiState = Content( isRegistered = isRegistered, + isHydrated = true, original = OriginalProfile( nickname = nickname, profileImageUrl = profileImageUrl, diff --git a/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUpdateResult.kt b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUpdateResult.kt new file mode 100644 index 00000000..6e7d597f --- /dev/null +++ b/Prezel/feature/profile/impl/src/main/java/com/team/prezel/feature/profile/impl/model/ProfileUpdateResult.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.profile.impl.model + +internal enum class ProfileUpdateResult { + Image, + Nickname, + Profile, +} diff --git a/Prezel/feature/profile/impl/src/main/res/values/strings.xml b/Prezel/feature/profile/impl/src/main/res/values/strings.xml index 3829289d..630d6023 100644 --- a/Prezel/feature/profile/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -19,4 +19,7 @@ 닉네임 중복 확인에 실패했어요. 유저 정보 조회에 실패했어요. 프로필 저장에 실패했어요. + 이미지를 변경했어요. + 닉네임을 변경했어요. + 프로필을 변경했어요. diff --git a/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt index 494c8044..5cb4de82 100644 --- a/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt +++ b/Prezel/feature/report/api/src/main/java/com/team/prezel/feature/report/api/ReportNavKey.kt @@ -7,5 +7,4 @@ import kotlinx.serialization.Serializable data class ReportNavKey( val presentationId: Long, val isPast: Boolean = false, - val showBackButton: Boolean = false, ) : NavKey diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt index 955537e0..d7b1f5f4 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/navigation/ReportEntryBuilder.kt @@ -33,7 +33,6 @@ private fun EntryProviderScope.reportEntry() { AnalysisReportScreen( onBack = { navigator.goBack() }, - showBackButton = key.showBackButton, navigateToAnalysisScript = { presentationId, isPast -> navigator.navigateToAnalysisScript( presentationId = presentationId, diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt index 3a22a110..332010ee 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportScreen.kt @@ -39,7 +39,6 @@ internal fun AnalysisReportScreen( navigateToSpeechAccuracy: (analysisResultId: Long) -> Unit, navigateToScriptMatch: (analysisResultId: Long) -> Unit, modifier: Modifier = Modifier, - showBackButton: Boolean = false, viewModel: AnalysisReportViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -55,8 +54,24 @@ internal fun AnalysisReportScreen( when (effect) { AnalysisReportUiEffect.NavigateToBack -> onBack() is AnalysisReportUiEffect.ShowMessage -> { + val contentState = uiState as? AnalysisReportUiState.Content + val shouldShowInputAction = when { + contentState == null -> false + contentState.isPast -> false + contentState.isScriptWritten -> false + else -> effect.message.requiresScriptInputAction + } + snackbarHostState.showPrezelSnackbar( message = resources.getString(effect.message.resId), + actionLabel = resources + .getString(R.string.feature_report_impl_snackbar_action_input) + .takeIf { shouldShowInputAction }, + onAction = { + if (shouldShowInputAction) { + viewModel.onIntent(AnalysisReportUiIntent.ClickReWriteScript) + } + }.takeIf { shouldShowInputAction }, useRaisedPosition = false, ) } @@ -75,7 +90,6 @@ internal fun AnalysisReportScreen( AnalysisReportScreen( uiState = uiState, - showBackButton = showBackButton, onBackClick = onBack, onDeleteClick = { viewModel.onIntent(AnalysisReportUiIntent.ClickDelete) }, onImprovementCardIndexChange = { index -> viewModel.onIntent(AnalysisReportUiIntent.ClickGrowthGraphItem(index)) }, @@ -106,13 +120,11 @@ internal fun AnalysisReportScreen( onSpeechAccuracyClick: () -> Unit, onScriptMatchClick: () -> Unit, modifier: Modifier = Modifier, - showBackButton: Boolean = false, ) { when (uiState) { is AnalysisReportUiState.Content -> { AnalysisReportScreenContent( uiState = uiState, - showBackButton = showBackButton, onBackClick = onBackClick, onDeleteClick = onDeleteClick, modifier = modifier, @@ -135,7 +147,6 @@ internal fun AnalysisReportScreen( @Composable private fun AnalysisReportScreenContent( uiState: AnalysisReportUiState.Content, - showBackButton: Boolean, onBackClick: () -> Unit, onDeleteClick: () -> Unit, onImprovementCardIndexChange: (index: Int) -> Unit, @@ -159,18 +170,14 @@ private fun AnalysisReportScreenContent( ReportScreenLayout( appBarTitle = uiState.presentationInfo.title, - leadingIcon = if (showBackButton) { - { - IconButton(onClick = onBackClick) { - Icon( - painter = painterResource(PrezelIcons.ArrowLeft), - contentDescription = stringResource(R.string.feature_report_impl_back), - tint = PrezelTheme.colors.iconRegular, - ) - } + leadingIcon = { + IconButton(onClick = onBackClick) { + Icon( + painter = painterResource(PrezelIcons.ArrowLeft), + contentDescription = stringResource(R.string.feature_report_impl_back), + tint = PrezelTheme.colors.iconRegular, + ) } - } else { - {} }, headerContent = { titleModifier -> ReportHeaderContent( @@ -199,6 +206,22 @@ private val AnalysisReportUiMessage.resId: Int @StringRes get() = when (this) { AnalysisReportUiMessage.FETCH_REPORT_FAILED -> R.string.feature_report_impl_fetch_report_failed AnalysisReportUiMessage.DELETE_REPORT_FAILED -> R.string.feature_report_impl_delete_report_failed + AnalysisReportUiMessage.SCRIPT_MATCH_ANALYSIS_UNAVAILABLE -> { + R.string.feature_report_impl_script_match_analysis_unavailable + } + AnalysisReportUiMessage.SPEECH_ANALYSIS_UNAVAILABLE -> { + R.string.feature_report_impl_speech_analysis_unavailable + } + } + +private val AnalysisReportUiMessage.requiresScriptInputAction: Boolean + get() = when (this) { + AnalysisReportUiMessage.SCRIPT_MATCH_ANALYSIS_UNAVAILABLE, + AnalysisReportUiMessage.SPEECH_ANALYSIS_UNAVAILABLE, + -> true + AnalysisReportUiMessage.FETCH_REPORT_FAILED, + AnalysisReportUiMessage.DELETE_REPORT_FAILED, + -> false } @BasicPreview diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt index 048903e3..f62cdf2e 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/AnalysisReportViewModel.kt @@ -131,11 +131,23 @@ internal class AnalysisReportViewModel @AssistedInject constructor( } private fun navigateToSpeechAccuracy() { + if (contentState?.accuracyScore == null) { + viewModelScope.launch { + sendEffect(AnalysisReportUiEffect.ShowMessage(AnalysisReportUiMessage.SCRIPT_MATCH_ANALYSIS_UNAVAILABLE)) + } + return + } val effect = analysisResultId?.let(AnalysisReportUiEffect::NavigateToSpeechAccuracy) ?: return viewModelScope.launch { sendEffect(effect) } } private fun navigateToScriptMatch() { + if (contentState?.scriptMatchRate == null) { + viewModelScope.launch { + sendEffect(AnalysisReportUiEffect.ShowMessage(AnalysisReportUiMessage.SPEECH_ANALYSIS_UNAVAILABLE)) + } + return + } val effect = analysisResultId?.let(AnalysisReportUiEffect::NavigateToScriptMatch) ?: return viewModelScope.launch { sendEffect(effect) } } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt index bc019a7a..fb4f4f12 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/body/AccuracySection.kt @@ -95,14 +95,12 @@ private fun AccuracyMetricCards( modifier = Modifier.weight(1f), title = stringResource(R.string.feature_report_impl_label_speech), value = accuracyScore.toPercentLabel(), - enabled = accuracyScore != null, onClick = onSpeechAccuracyClick, ) MetricResultCard( modifier = Modifier.weight(1f), title = stringResource(R.string.feature_report_impl_label_script_match), value = scriptMatchRate.toPercentLabel(), - enabled = scriptMatchRate != null, onClick = onScriptMatchClick, ) } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt index 1de73dbc..a66d24b3 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/component/common/ReportMetricCards.kt @@ -27,13 +27,11 @@ internal fun MetricResultCard( value: String, onClick: () -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = false, ) { PrezelTouchArea( modifier = modifier .clip(shape = PrezelTheme.shapes.V8) .background(color = PrezelTheme.colors.bgMedium), - enabled = enabled, shape = PrezelTheme.shapes.V8, onClick = onClick, ) { @@ -66,8 +64,8 @@ internal fun MetricResultCard( size = ButtonSize.SMALL, hierarchy = ButtonHierarchy.SECONDARY, modifier = Modifier.align(Alignment.TopEnd), - enabled = enabled, isUseRipple = false, + enabled = false, onClick = onClick, ) } diff --git a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportUiMessage.kt b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportUiMessage.kt index 1180f3df..aa4f3558 100644 --- a/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportUiMessage.kt +++ b/Prezel/feature/report/impl/src/main/java/com/team/prezel/feature/report/impl/report/model/AnalysisReportUiMessage.kt @@ -3,4 +3,6 @@ package com.team.prezel.feature.report.impl.report.model internal enum class AnalysisReportUiMessage { FETCH_REPORT_FAILED, DELETE_REPORT_FAILED, + SCRIPT_MATCH_ANALYSIS_UNAVAILABLE, + SPEECH_ANALYSIS_UNAVAILABLE, } diff --git a/Prezel/feature/report/impl/src/main/res/values/strings.xml b/Prezel/feature/report/impl/src/main/res/values/strings.xml index c7364a23..085ec0df 100644 --- a/Prezel/feature/report/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/report/impl/src/main/res/values/strings.xml @@ -60,6 +60,9 @@ 리포트를 불러오지 못했습니다. 대본 분석 결과를 불러오지 못했습니다. 리포트를 삭제하지 못했습니다. + 입력하기 + 대본을 입력하지 않아 대본 일치율을 분석할 수 없어요. + 대본을 입력하지 않아 발화를 분석할 수 없어요. 닫기 정확도 상세를 불러오지 못했습니다. 발화 분석 상세가 없어요. diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountScreen.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountScreen.kt index eb34badf..c7142b14 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountScreen.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/DeleteAccountScreen.kt @@ -3,19 +3,29 @@ package com.team.prezel.feature.setting.impl.delete import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.PrezelCheckbox import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.core.ui.util.noRippleClickable import com.team.prezel.feature.setting.impl.R import com.team.prezel.feature.setting.impl.delete.component.DeleteAccountActionSection import com.team.prezel.feature.setting.impl.delete.component.DeleteAccountConfirmDialog @@ -47,6 +57,7 @@ internal fun DeleteAccountScreen( navigateToSplash() snackbarHostState.showPrezelSnackbar(message = resources.getString(R.string.feature_setting_impl_delete_account_withdraw_success)) } + is DeleteAccountUiEffect.ShowMessage -> { snackbarHostState.showPrezelSnackbar(message = resources.getString(effect.message.toMessageRes())) } @@ -104,7 +115,6 @@ private fun DeleteAccountScreen( DeleteAccountContent( uiState = uiState, - onToggleNoticeChecked = onToggleNoticeChecked, onSelectReason = onSelectReason, onOtherReasonChanged = onOtherReasonChanged, modifier = Modifier @@ -112,29 +122,34 @@ private fun DeleteAccountScreen( .fillMaxWidth(), ) - DeleteAccountActionSection( - step = uiState.step, - enabled = uiState.isPrimaryActionEnabled, - onClickNext = onClickNext, - onClickWithdraw = onClickWithdraw, - ) + Column(modifier = Modifier.fillMaxWidth()) { + if (uiState.step == DeleteAccountStep.NOTICE) { + DeletionAgreement( + isChecked = uiState.isNoticeChecked, + onCheckedChange = onToggleNoticeChecked, + modifier = Modifier.padding(horizontal = PrezelTheme.spacing.V20), + ) + } + DeleteAccountActionSection( + step = uiState.step, + enabled = uiState.isPrimaryActionEnabled, + onClickNext = onClickNext, + onClickWithdraw = onClickWithdraw, + ) + } } } @Composable private fun DeleteAccountContent( uiState: DeleteAccountUiState, - onToggleNoticeChecked: (Boolean) -> Unit, onSelectReason: (DeleteAccountReasonOption) -> Unit, onOtherReasonChanged: (String) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { when (uiState.step) { - DeleteAccountStep.NOTICE -> DeleteAccountNoticeStep( - isChecked = uiState.isNoticeChecked, - onCheckedChange = onToggleNoticeChecked, - ) + DeleteAccountStep.NOTICE -> DeleteAccountNoticeStep() DeleteAccountStep.REASON -> DeleteAccountReasonStep( selectedReason = uiState.selectedReason, @@ -146,6 +161,34 @@ private fun DeleteAccountContent( } } +@Composable +private fun DeletionAgreement( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .noRippleClickable { onCheckedChange(!isChecked) }, + verticalAlignment = Alignment.CenterVertically, + ) { + PrezelCheckbox( + checked = isChecked, + onCheckedChange = onCheckedChange, + extraTouchPadding = PaddingValues(), + ) + + Spacer(modifier = Modifier.width(PrezelTheme.spacing.V8)) + + Text( + text = stringResource(R.string.feature_setting_impl_delete_account_notice_check), + style = PrezelTheme.typography.body3Regular, + color = PrezelTheme.colors.textRegular, + ) + } +} + @BasicPreview @Composable private fun DeleteAccountNoticeStepScreenPreview() { diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountNoticeStep.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountNoticeStep.kt index 56d0a152..be34a4bd 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountNoticeStep.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/delete/component/DeleteAccountNoticeStep.kt @@ -3,19 +3,15 @@ package com.team.prezel.feature.setting.impl.delete.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -24,10 +20,8 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.em -import com.team.prezel.core.designsystem.component.PrezelCheckbox import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme -import com.team.prezel.core.ui.util.noRippleClickable import com.team.prezel.feature.setting.impl.R private const val HIGHLIGHT_START_TAG = "" @@ -43,11 +37,7 @@ private val supportedTags = listOf( ) @Composable -internal fun DeleteAccountNoticeStep( - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { +internal fun DeleteAccountNoticeStep(modifier: Modifier = Modifier) { Column( modifier = modifier .fillMaxSize() @@ -62,8 +52,6 @@ internal fun DeleteAccountNoticeStep( DeleteAccountNoticeDetailBox() Spacer(modifier = Modifier.height(PrezelTheme.spacing.V36)) - - DeletionAgreement(isChecked = isChecked, onCheckedChange = onCheckedChange) } } @@ -82,34 +70,6 @@ private fun DeleteAccountNoticeHeader(modifier: Modifier = Modifier) { } } -@Composable -private fun DeletionAgreement( - isChecked: Boolean, - onCheckedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .noRippleClickable { onCheckedChange(!isChecked) }, - verticalAlignment = Alignment.CenterVertically, - ) { - PrezelCheckbox( - checked = isChecked, - onCheckedChange = onCheckedChange, - extraTouchPadding = PaddingValues(), - ) - - Spacer(modifier = Modifier.width(PrezelTheme.spacing.V8)) - - Text( - text = stringResource(R.string.feature_setting_impl_delete_account_notice_check), - style = PrezelTheme.typography.body3Regular, - color = PrezelTheme.colors.textRegular, - ) - } -} - @Composable private fun DeleteAccountNoticeDescription(modifier: Modifier = Modifier) { val highlightStyle = SpanStyle(color = PrezelTheme.colors.feedbackBadRegular) @@ -322,9 +282,6 @@ private data class TagState( @Composable private fun DeleteAccountNoticeStepPreview() { PrezelTheme { - DeleteAccountNoticeStep( - isChecked = false, - onCheckedChange = {}, - ) + DeleteAccountNoticeStep() } } diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/navigation/SettingEntryBuilder.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/navigation/SettingEntryBuilder.kt index 1a4c55e4..538bca21 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/navigation/SettingEntryBuilder.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/navigation/SettingEntryBuilder.kt @@ -8,7 +8,6 @@ import com.team.prezel.feature.setting.api.SettingNavKey import com.team.prezel.feature.setting.impl.delete.DeleteAccountScreen import com.team.prezel.feature.setting.impl.setting.SettingScreen import com.team.prezel.feature.splash.api.SplashNavKey -import com.team.prezel.feature.terms.api.TermsDocumentType import com.team.prezel.feature.terms.api.TermsNavKey import dagger.Module import dagger.Provides @@ -24,11 +23,8 @@ internal fun EntryProviderScope.featureSettingEntryBuilder() { navigateBack = { navigator.goBack() }, navigateToDeleteAccount = { navigator.navigate(DeleteAccountNavKey) }, navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, - navigateToTermsOfService = { - navigator.navigate(TermsNavKey.Detail(TermsDocumentType.TERMS_OF_SERVICE)) - }, - navigateToPrivacyPolicy = { - navigator.navigate(TermsNavKey.Detail(TermsDocumentType.PRIVACY_POLICY)) + navigateToTermsDetail = { title, url -> + navigator.navigate(TermsNavKey.Detail(title = title, url = url)) }, ) } diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingScreen.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingScreen.kt index 49989a74..9e1e0e26 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingScreen.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingScreen.kt @@ -31,8 +31,7 @@ internal fun SettingScreen( navigateBack: () -> Unit, navigateToDeleteAccount: () -> Unit, navigateToSplash: () -> Unit, - navigateToTermsOfService: () -> Unit, - navigateToPrivacyPolicy: () -> Unit, + navigateToTermsDetail: (String, String) -> Unit, modifier: Modifier = Modifier, viewModel: SettingViewModel = hiltViewModel(), ) { @@ -52,6 +51,10 @@ internal fun SettingScreen( snackbarHostState.showPrezelSnackbar(message = resource.getString(R.string.feature_setting_impl_logout_success)) } + is SettingUiEffect.NavigateToTermsDetail -> { + navigateToTermsDetail(effect.title, effect.url) + } + is SettingUiEffect.ShowMessage -> { snackbarHostState.showPrezelSnackbar(message = resource.getString(effect.message.toMessageRes())) } @@ -69,8 +72,8 @@ internal fun SettingScreen( SettingScreen( uiState = uiState, onClickBack = navigateBack, - onClickTermsOfService = navigateToTermsOfService, - onClickPrivacyPolicy = navigateToPrivacyPolicy, + onClickTermsOfService = { viewModel.onIntent(SettingUiIntent.ClickTermsOfService) }, + onClickPrivacyPolicy = { viewModel.onIntent(SettingUiIntent.ClickPrivacyPolicy) }, onClickLogout = { shouldShowLogoutDialog = true }, onClickWithdraw = navigateToDeleteAccount, modifier = modifier, diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingViewModel.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingViewModel.kt index 194708d4..8861017f 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingViewModel.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/SettingViewModel.kt @@ -2,13 +2,16 @@ package com.team.prezel.feature.setting.impl.setting import androidx.lifecycle.viewModelScope import com.team.prezel.core.domain.usecase.auth.LogoutUseCase +import com.team.prezel.core.domain.usecase.terms.FetchTermsUseCase import com.team.prezel.core.domain.usecase.user.FetchUserInfoUseCase +import com.team.prezel.core.model.terms.Term import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.setting.impl.setting.contract.SettingUiEffect import com.team.prezel.feature.setting.impl.setting.contract.SettingUiIntent import com.team.prezel.feature.setting.impl.setting.contract.SettingUiState import com.team.prezel.feature.setting.impl.setting.model.SettingUiMessage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -16,36 +19,66 @@ import javax.inject.Inject @HiltViewModel internal class SettingViewModel @Inject constructor( private val fetchUserInfoUseCase: FetchUserInfoUseCase, + private val fetchTermsUseCase: FetchTermsUseCase, private val logoutUseCase: LogoutUseCase, ) : BaseViewModel(SettingUiState()) { override fun onIntent(intent: SettingUiIntent) { when (intent) { - SettingUiIntent.FetchData -> fetchUserInfo() + SettingUiIntent.FetchData -> fetchData() + SettingUiIntent.ClickTermsOfService -> navigateToTermsDetail(TermsCategory.TERMS_OF_SERVICE) + SettingUiIntent.ClickPrivacyPolicy -> navigateToTermsDetail(TermsCategory.PRIVACY_POLICY) SettingUiIntent.ClickLogout -> logout() } } - private fun fetchUserInfo() { + private fun fetchData() { updateState { copy(isLoading = true) } viewModelScope .launch { - fetchUserInfoUseCase() - .onSuccess { user -> - updateState { - copy( - profileImageUrl = user.profileImageUrl, - nickname = user.nickname, - email = user.email, - ) - } - }.onFailure { exception -> - Timber.e(t = exception) - sendEffect(SettingUiEffect.ShowMessage(SettingUiMessage.FETCH_USER_INFO_FAILED)) - } + fetchUserInfo() + fetchTerms() }.invokeOnCompletion { updateState { copy(isLoading = false) } } } + private suspend fun fetchUserInfo() { + fetchUserInfoUseCase() + .onSuccess { user -> + updateState { + copy( + profileImageUrl = user.profileImageUrl, + nickname = user.nickname, + email = user.email, + ) + } + }.onFailure { exception -> + Timber.e(t = exception) + sendEffect(SettingUiEffect.ShowMessage(SettingUiMessage.FETCH_USER_INFO_FAILED)) + } + } + + private suspend fun fetchTerms() { + fetchTermsUseCase() + .onSuccess { terms -> + updateState { copy(terms = terms.toImmutableList()) } + }.onFailure { exception -> + Timber.e(t = exception) + } + } + + private fun navigateToTermsDetail(category: TermsCategory) { + val term = currentState.terms.firstOrNull { it.matches(category) } ?: return + + viewModelScope.launch { + sendEffect( + SettingUiEffect.NavigateToTermsDetail( + title = term.title, + url = term.content, + ), + ) + } + } + private fun logout() { viewModelScope.launch { logoutUseCase() @@ -57,4 +90,15 @@ internal class SettingViewModel @Inject constructor( } } } + + private fun Term.matches(category: TermsCategory): Boolean = + when (category) { + TermsCategory.TERMS_OF_SERVICE -> title.contains("이용약관") + TermsCategory.PRIVACY_POLICY -> title.contains("개인정보") + } + + private enum class TermsCategory { + TERMS_OF_SERVICE, + PRIVACY_POLICY, + } } diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiEffect.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiEffect.kt index de89c4b2..83378a60 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiEffect.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiEffect.kt @@ -6,6 +6,11 @@ import com.team.prezel.feature.setting.impl.setting.model.SettingUiMessage internal sealed interface SettingUiEffect : UiEffect { data object NavigateToSplash : SettingUiEffect + data class NavigateToTermsDetail( + val title: String, + val url: String, + ) : SettingUiEffect + data class ShowMessage( val message: SettingUiMessage, ) : SettingUiEffect diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiIntent.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiIntent.kt index 5722f31e..f87901d3 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiIntent.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiIntent.kt @@ -5,5 +5,9 @@ import com.team.prezel.core.ui.base.UiIntent internal sealed interface SettingUiIntent : UiIntent { data object FetchData : SettingUiIntent + data object ClickTermsOfService : SettingUiIntent + + data object ClickPrivacyPolicy : SettingUiIntent + data object ClickLogout : SettingUiIntent } diff --git a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiState.kt b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiState.kt index db19cca0..8e3c267c 100644 --- a/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiState.kt +++ b/Prezel/feature/setting/impl/src/main/java/com/team/prezel/feature/setting/impl/setting/contract/SettingUiState.kt @@ -1,7 +1,10 @@ package com.team.prezel.feature.setting.impl.setting.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.terms.Term import com.team.prezel.core.ui.base.UiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal data class SettingUiState( @@ -9,4 +12,5 @@ internal data class SettingUiState( val profileImageUrl: String? = null, val nickname: String = "", val email: String = "", + val terms: ImmutableList = persistentListOf(), ) : UiState diff --git a/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsDocumentType.kt b/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsDocumentType.kt deleted file mode 100644 index 48c87dd9..00000000 --- a/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsDocumentType.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.team.prezel.feature.terms.api - -import kotlinx.serialization.Serializable - -@Serializable -enum class TermsDocumentType { - TERMS_OF_SERVICE, - PRIVACY_POLICY, -} diff --git a/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt b/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt index fea30c17..438c38ef 100644 --- a/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt +++ b/Prezel/feature/terms/api/src/main/java/com/team/prezel/feature/terms/api/TermsNavKey.kt @@ -10,6 +10,7 @@ sealed interface TermsNavKey : NavKey { @Serializable data class Detail( - val document: TermsDocumentType, + val title: String, + val url: String, ) : TermsNavKey } diff --git a/Prezel/feature/terms/impl/build.gradle.kts b/Prezel/feature/terms/impl/build.gradle.kts index 79211e4e..af763d5b 100644 --- a/Prezel/feature/terms/impl/build.gradle.kts +++ b/Prezel/feature/terms/impl/build.gradle.kts @@ -1,20 +1,9 @@ -import com.team.prezel.buildlogic.convention.external.localProperty - plugins { alias(libs.plugins.prezel.android.feature.impl) } android { namespace = "com.team.prezel.feature.terms.impl" - - buildFeatures { - buildConfig = true - } - - defaultConfig { - buildConfigField("String", "PRIVACY_POLICY_URL", "\"${localProperty("privacy.policy.url").get()}\"") - buildConfigField("String", "TERMS_OF_SERVICE_URL", "\"${localProperty("terms.of.service.url").get()}\"") - } } dependencies { @@ -23,4 +12,6 @@ dependencies { implementation(projects.featureTermsApi) implementation(projects.featureProfileApi) + + implementation(libs.kotlinx.collections.immutable) } diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt index 5b3fe132..bd0e95c4 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsScreen.kt @@ -49,13 +49,15 @@ import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.terms.impl.contract.TermsUiEffect import com.team.prezel.feature.terms.impl.contract.TermsUiIntent import com.team.prezel.feature.terms.impl.contract.TermsUiState +import com.team.prezel.feature.terms.impl.model.TermsAgreementUiModel import com.team.prezel.feature.terms.impl.model.TermsUiMessage +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList @Composable internal fun TermsScreen( navigateBack: () -> Unit, - navigateToTermsOfServiceDetail: () -> Unit, - navigateToPrivacyPolicyDetail: () -> Unit, + navigateToTermsDetail: (String, String) -> Unit, navigateToProfile: () -> Unit, modifier: Modifier = Modifier, viewModel: TermsViewModel = hiltViewModel(), @@ -65,12 +67,17 @@ internal fun TermsScreen( val snackbarHostState = LocalSnackbarHostState.current LaunchedEffect(Unit) { + viewModel.onIntent(TermsUiIntent.FetchTerms) + viewModel.uiEffect.collect { effect -> when (effect) { TermsUiEffect.NavigateToProfile -> navigateToProfile() is TermsUiEffect.ShowMessage -> { val resId = when (effect.message) { + TermsUiMessage.FETCH_TERMS_FAILED_NETWORK -> R.string.feature_terms_impl_fetch_terms_failed_network + TermsUiMessage.FETCH_TERMS_FAILED_SERVER -> R.string.feature_terms_impl_fetch_terms_failed_server + TermsUiMessage.FETCH_TERMS_FAILED_UNKNOWN -> R.string.feature_terms_impl_fetch_terms_failed_unknown TermsUiMessage.AGREE_TERMS_FAILED_INVALID_REQUEST -> R.string.feature_terms_impl_agree_terms_failed_invalid_request TermsUiMessage.AGREE_TERMS_FAILED_NETWORK -> R.string.feature_terms_impl_agree_terms_failed_network TermsUiMessage.AGREE_TERMS_FAILED_SERVER -> R.string.feature_terms_impl_agree_terms_failed_server @@ -86,11 +93,8 @@ internal fun TermsScreen( uiState = uiState, onBack = navigateBack, onToggleAll = { viewModel.onIntent(TermsUiIntent.ToggleAll) }, - onToggleTermsOfService = { viewModel.onIntent(TermsUiIntent.ToggleTermsOfService) }, - onTogglePrivacyPolicy = { viewModel.onIntent(TermsUiIntent.TogglePrivacyPolicy) }, - onToggleMarketingConsent = { viewModel.onIntent(TermsUiIntent.ToggleMarketingConsent) }, - onClickTermsOfServiceDetail = navigateToTermsOfServiceDetail, - onClickPrivacyPolicyDetail = navigateToPrivacyPolicyDetail, + onToggleTerm = { termsId -> viewModel.onIntent(TermsUiIntent.ToggleTerm(termsId)) }, + onClickTermsDetail = navigateToTermsDetail, onContinue = { viewModel.onIntent(TermsUiIntent.ClickContinue) }, modifier = modifier, ) @@ -102,11 +106,8 @@ private fun TermsScreenScreen( uiState: TermsUiState, onBack: () -> Unit, onToggleAll: () -> Unit, - onToggleTermsOfService: () -> Unit, - onTogglePrivacyPolicy: () -> Unit, - onToggleMarketingConsent: () -> Unit, - onClickTermsOfServiceDetail: () -> Unit, - onClickPrivacyPolicyDetail: () -> Unit, + onToggleTerm: (Long) -> Unit, + onClickTermsDetail: (String, String) -> Unit, onContinue: () -> Unit, modifier: Modifier = Modifier, ) { @@ -117,20 +118,17 @@ private fun TermsScreenScreen( modifier = Modifier.weight(1f), uiState = uiState, onToggleAll = onToggleAll, - onToggleTermsOfService = onToggleTermsOfService, - onClickTermsOfServiceDetail = onClickTermsOfServiceDetail, - onTogglePrivacyPolicy = onTogglePrivacyPolicy, - onClickPrivacyPolicyDetail = onClickPrivacyPolicyDetail, - onToggleMarketingConsent = onToggleMarketingConsent, + onToggleTerm = onToggleTerm, + onClickTermsDetail = onClickTermsDetail, ) PrezelButtonArea( - mainButton = { modifier -> + mainButton = { buttonModifier -> PrezelButton( - modifier = modifier, + modifier = buttonModifier, text = stringResource(R.string.feature_terms_impl_terms_continue_button_text), onClick = onContinue, - enabled = uiState.isRequiredChecked && !uiState.isLoading, + enabled = uiState.isRequiredChecked && !uiState.isLoading && !uiState.isTermsLoading, type = ButtonType.FILLED, hierarchy = ButtonHierarchy.PRIMARY, ) @@ -165,19 +163,15 @@ private fun TermsScreenTopAppBar( private fun TermsAgreementContent( uiState: TermsUiState, onToggleAll: () -> Unit, - onToggleTermsOfService: () -> Unit, - onClickTermsOfServiceDetail: () -> Unit, - onTogglePrivacyPolicy: () -> Unit, - onClickPrivacyPolicyDetail: () -> Unit, - onToggleMarketingConsent: () -> Unit, + onToggleTerm: (Long) -> Unit, + onClickTermsDetail: (String, String) -> Unit, modifier: Modifier = Modifier, ) { Column( - modifier = modifier - .padding( - horizontal = PrezelTheme.spacing.V20, - vertical = PrezelTheme.spacing.V16, - ), + modifier = modifier.padding( + horizontal = PrezelTheme.spacing.V20, + vertical = PrezelTheme.spacing.V16, + ), ) { TermsSelectAllRow( isAllChecked = uiState.isAllChecked, @@ -187,30 +181,13 @@ private fun TermsAgreementContent( PrezelHorizontalDivider(type = PrezelDividerType.THICK) TermsAgreementSection { - TermsAgreementRow( - checked = uiState.isTermsOfServiceChecked, - title = stringResource(R.string.feature_terms_impl_terms_of_service), - summary = stringResource(R.string.feature_terms_impl_terms_of_service_summary), - isShowDetailButton = true, - onCheckedChange = { onToggleTermsOfService() }, - onClickDetail = onClickTermsOfServiceDetail, - ) - TermsAgreementRow( - checked = uiState.isPrivacyPolicyChecked, - title = stringResource(R.string.feature_terms_impl_privacy_policy), - summary = stringResource(R.string.feature_terms_impl_privacy_policy_summary), - isShowDetailButton = true, - onCheckedChange = { onTogglePrivacyPolicy() }, - onClickDetail = onClickPrivacyPolicyDetail, - ) - TermsAgreementRow( - checked = uiState.isMarketingConsentChecked, - title = stringResource(R.string.feature_terms_impl_marketing_consent), - summary = stringResource(R.string.feature_terms_impl_marketing_consent_summary), - isShowDetailButton = false, - onCheckedChange = { onToggleMarketingConsent() }, - onClickDetail = {}, - ) + uiState.terms.forEach { term -> + TermsAgreementRow( + term = term, + onCheckedChange = { onToggleTerm(term.termsId) }, + onClickDetail = { onClickTermsDetail(term.titleWithRequirement(), term.contentUrl) }, + ) + } } } } @@ -262,28 +239,25 @@ private fun TermsAgreementSection( @Composable private fun TermsAgreementRow( - checked: Boolean, - title: String, - summary: String, - isShowDetailButton: Boolean, + term: TermsAgreementUiModel, onCheckedChange: (Boolean) -> Unit, onClickDetail: () -> Unit, modifier: Modifier = Modifier, ) { PrezelAccordion( modifier = modifier, - title = title, + title = term.titleWithRequirement(), nested = true, leadingContent = { PrezelCheckbox( - checked = checked, + checked = term.isChecked, size = CheckboxSize.REGULAR, onCheckedChange = onCheckedChange, extraTouchPadding = PaddingValues(), ) }, trailingContent = { - if (isShowDetailButton) { + if (term.contentUrl.isNotBlank()) { PrezelHyperlinkButton( text = stringResource(R.string.feature_terms_impl_terms_detail_button_text), onClick = onClickDetail, @@ -299,7 +273,7 @@ private fun TermsAgreementRow( .padding(all = PrezelTheme.spacing.V12), ) { Text( - text = summary, + text = term.summary, style = PrezelTheme.typography.caption2Regular, color = PrezelTheme.colors.textLarge, ) @@ -307,10 +281,37 @@ private fun TermsAgreementRow( } } +private fun TermsAgreementUiModel.titleWithRequirement(): String = + buildString { + append(if (isRequired) "(필수) " else "(선택) ") + append(title) + } + @Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) @Composable private fun TermsScreenPreview() { - var uiState by remember { mutableStateOf(TermsUiState()) } + var uiState by remember { + mutableStateOf( + TermsUiState( + terms = persistentListOf( + TermsAgreementUiModel( + termsId = 6L, + title = "이용약관", + summary = "서비스 이용과 관련한 기본적인 권리·의무 및 책임사항을 규정합니다.", + contentUrl = "https://example.com/terms", + isRequired = true, + ), + TermsAgreementUiModel( + termsId = 7L, + title = "개인정보처리방침", + summary = "서비스 제공을 위한 음성 녹음 및 분석 등 개인정보 수집·이용 안내입니다.", + contentUrl = "https://example.com/privacy", + isRequired = true, + ), + ), + ), + ) + } PrezelTheme { TermsScreenScreen( @@ -319,16 +320,18 @@ private fun TermsScreenPreview() { onToggleAll = { val next = !uiState.isAllChecked uiState = uiState.copy( - isTermsOfServiceChecked = next, - isPrivacyPolicyChecked = next, - isMarketingConsentChecked = next, + terms = uiState.terms.map { term -> term.copy(isChecked = next) }.toImmutableList(), + ) + }, + onToggleTerm = { termsId -> + uiState = uiState.copy( + terms = uiState.terms + .map { term -> + if (term.termsId == termsId) term.copy(isChecked = !term.isChecked) else term + }.toImmutableList(), ) }, - onToggleTermsOfService = { uiState = uiState.copy(isTermsOfServiceChecked = !uiState.isTermsOfServiceChecked) }, - onTogglePrivacyPolicy = { uiState = uiState.copy(isPrivacyPolicyChecked = !uiState.isPrivacyPolicyChecked) }, - onToggleMarketingConsent = { uiState = uiState.copy(isMarketingConsentChecked = !uiState.isMarketingConsentChecked) }, - onClickTermsOfServiceDetail = {}, - onClickPrivacyPolicyDetail = {}, + onClickTermsDetail = { _, _ -> }, onContinue = {}, ) } diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt index e57f2dc8..65be5e85 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/TermsViewModel.kt @@ -4,81 +4,119 @@ import androidx.lifecycle.viewModelScope import com.team.prezel.core.common.error.AppError import com.team.prezel.core.common.error.AppException import com.team.prezel.core.domain.usecase.terms.AgreeTermsUseCase +import com.team.prezel.core.domain.usecase.terms.FetchTermsUseCase +import com.team.prezel.core.model.terms.Term import com.team.prezel.core.model.terms.TermsAgreement import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.terms.impl.contract.TermsUiEffect import com.team.prezel.feature.terms.impl.contract.TermsUiIntent import com.team.prezel.feature.terms.impl.contract.TermsUiState +import com.team.prezel.feature.terms.impl.model.TermsAgreementUiModel import com.team.prezel.feature.terms.impl.model.TermsUiMessage import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import javax.inject.Inject -private const val TERMS_OF_SERVICE_ID = 1L -private const val PRIVACY_POLICY_ID = 2L -private const val MARKETING_CONSENT_ID = 3L - @HiltViewModel internal class TermsViewModel @Inject constructor( + private val fetchTermsUseCase: FetchTermsUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, ) : BaseViewModel(TermsUiState()) { override fun onIntent(intent: TermsUiIntent) { when (intent) { + TermsUiIntent.FetchTerms -> fetchTerms() TermsUiIntent.ToggleAll -> toggleAll() - TermsUiIntent.ToggleTermsOfService -> toggleTermsOfService() - TermsUiIntent.TogglePrivacyPolicy -> togglePrivacyPolicy() - TermsUiIntent.ToggleMarketingConsent -> toggleMarketingConsent() + is TermsUiIntent.ToggleTerm -> toggleTerm(intent.termsId) TermsUiIntent.ClickContinue -> handleClickContinue() } } + private fun fetchTerms() { + if (currentState.isTermsLoading) return + + viewModelScope.launch { + updateState { copy(isTermsLoading = true) } + + fetchTermsUseCase() + .onSuccess { terms -> + updateState { + copy( + terms = terms.map { term -> term.toUiModel() }.toImmutableList(), + ) + } + }.onFailure { throwable -> + sendEffect(TermsUiEffect.ShowMessage(message = throwable.toFetchTermsUiMessage())) + } + + updateState { copy(isTermsLoading = false) } + } + } + private fun toggleAll() { val newChecked = !currentState.isAllChecked updateState { copy( - isTermsOfServiceChecked = newChecked, - isPrivacyPolicyChecked = newChecked, - isMarketingConsentChecked = newChecked, + terms = terms.map { term -> term.copy(isChecked = newChecked) }.toImmutableList(), ) } } - private fun toggleTermsOfService() { - updateState { copy(isTermsOfServiceChecked = !isTermsOfServiceChecked) } - } - - private fun togglePrivacyPolicy() { - updateState { copy(isPrivacyPolicyChecked = !isPrivacyPolicyChecked) } - } - - private fun toggleMarketingConsent() { - updateState { copy(isMarketingConsentChecked = !isMarketingConsentChecked) } + private fun toggleTerm(termsId: Long) { + updateState { + copy( + terms = terms + .map { term -> + if (term.termsId == termsId) { + term.copy(isChecked = !term.isChecked) + } else { + term + } + }.toImmutableList(), + ) + } } private fun handleClickContinue() { - if (!currentState.isRequiredChecked || currentState.isLoading) return + if (!currentState.isRequiredChecked || currentState.isLoading || currentState.isTermsLoading) return viewModelScope.launch { updateState { copy(isLoading = true) } agreeTermsUseCase( - terms = listOf( - TermsAgreement(termsId = TERMS_OF_SERVICE_ID, isAgreed = currentState.isTermsOfServiceChecked), - TermsAgreement(termsId = PRIVACY_POLICY_ID, isAgreed = currentState.isPrivacyPolicyChecked), - TermsAgreement(termsId = MARKETING_CONSENT_ID, isAgreed = currentState.isMarketingConsentChecked), - ), + terms = currentState.terms.map { term -> + TermsAgreement(termsId = term.termsId, isAgreed = term.isChecked) + }, ).onSuccess { sendEffect(TermsUiEffect.NavigateToProfile) }.onFailure { throwable -> - sendEffect(TermsUiEffect.ShowMessage(message = throwable.toTermsUiMessage())) + sendEffect(TermsUiEffect.ShowMessage(message = throwable.toAgreeTermsUiMessage())) } updateState { copy(isLoading = false) } } } - private fun Throwable.toTermsUiMessage(): TermsUiMessage { + private fun Throwable.toFetchTermsUiMessage(): TermsUiMessage { + val error = (this as? AppException)?.error + + return when (error) { + AppError.NETWORK -> TermsUiMessage.FETCH_TERMS_FAILED_NETWORK + AppError.SERVER_ERROR -> TermsUiMessage.FETCH_TERMS_FAILED_SERVER + AppError.UNAUTHORIZED, + AppError.INVALID_REQUEST, + AppError.NOT_FOUND, + AppError.DUPLICATE, + AppError.UNKNOWN, + null, + -> TermsUiMessage.FETCH_TERMS_FAILED_UNKNOWN + + else -> TermsUiMessage.FETCH_TERMS_FAILED_UNKNOWN + } + } + + private fun Throwable.toAgreeTermsUiMessage(): TermsUiMessage { val error = (this as? AppException)?.error return when (error) { @@ -97,4 +135,13 @@ internal class TermsViewModel @Inject constructor( else -> TermsUiMessage.AGREE_TERMS_FAILED_UNKNOWN } } + + private fun Term.toUiModel(): TermsAgreementUiModel = + TermsAgreementUiModel( + termsId = termsId, + title = title, + summary = summary, + contentUrl = content, + isRequired = isRequired, + ) } diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/component/TermsDetailModal.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/component/TermsDetailModal.kt index ecf83bf2..58df0336 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/component/TermsDetailModal.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/component/TermsDetailModal.kt @@ -27,6 +27,7 @@ import com.team.prezel.feature.terms.impl.R @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun TermsDetailModal( + title: String, url: String, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -40,6 +41,7 @@ internal fun TermsDetailModal( .fillMaxSize(), ) { PrezelTopAppBar( + title = { androidx.compose.material3.Text(text = title) }, trailingIcons = { IconButton(onClick = onDismiss) { Icon( diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiIntent.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiIntent.kt index 5252ab40..135236d3 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiIntent.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiIntent.kt @@ -3,13 +3,13 @@ package com.team.prezel.feature.terms.impl.contract import com.team.prezel.core.ui.base.UiIntent internal sealed interface TermsUiIntent : UiIntent { - data object ToggleAll : TermsUiIntent - - data object ToggleTermsOfService : TermsUiIntent + data object FetchTerms : TermsUiIntent - data object TogglePrivacyPolicy : TermsUiIntent + data object ToggleAll : TermsUiIntent - data object ToggleMarketingConsent : TermsUiIntent + data class ToggleTerm( + val termsId: Long, + ) : TermsUiIntent data object ClickContinue : TermsUiIntent } diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiState.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiState.kt index 1d03a5fb..b39378ba 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiState.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/contract/TermsUiState.kt @@ -2,15 +2,17 @@ package com.team.prezel.feature.terms.impl.contract import androidx.compose.runtime.Immutable import com.team.prezel.core.ui.base.UiState +import com.team.prezel.feature.terms.impl.model.TermsAgreementUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal data class TermsUiState( - val isTermsOfServiceChecked: Boolean = false, - val isPrivacyPolicyChecked: Boolean = false, - val isMarketingConsentChecked: Boolean = false, + val terms: ImmutableList = persistentListOf(), + val isTermsLoading: Boolean = false, val isLoading: Boolean = false, ) : UiState { - val isRequiredChecked: Boolean = isTermsOfServiceChecked && isPrivacyPolicyChecked + val isRequiredChecked: Boolean = terms.any { it.isRequired } && terms.filter { it.isRequired }.all { it.isChecked } - val isAllChecked: Boolean = isTermsOfServiceChecked && isPrivacyPolicyChecked && isMarketingConsentChecked + val isAllChecked: Boolean = terms.isNotEmpty() && terms.all { it.isChecked } } diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/model/TermsAgreementUiModel.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/model/TermsAgreementUiModel.kt new file mode 100644 index 00000000..6e2892a1 --- /dev/null +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/model/TermsAgreementUiModel.kt @@ -0,0 +1,13 @@ +package com.team.prezel.feature.terms.impl.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class TermsAgreementUiModel( + val termsId: Long, + val title: String, + val summary: String, + val contentUrl: String, + val isRequired: Boolean, + val isChecked: Boolean = false, +) diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/model/TermsUiMessage.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/model/TermsUiMessage.kt index 8b531afb..de351339 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/model/TermsUiMessage.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/model/TermsUiMessage.kt @@ -1,6 +1,9 @@ package com.team.prezel.feature.terms.impl.model internal enum class TermsUiMessage { + FETCH_TERMS_FAILED_NETWORK, + FETCH_TERMS_FAILED_SERVER, + FETCH_TERMS_FAILED_UNKNOWN, AGREE_TERMS_FAILED_INVALID_REQUEST, AGREE_TERMS_FAILED_NETWORK, AGREE_TERMS_FAILED_SERVER, diff --git a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt index 5bf6efa7..7e7feeeb 100644 --- a/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt +++ b/Prezel/feature/terms/impl/src/main/java/com/team/prezel/feature/terms/impl/navigation/TermsEntryBuilder.kt @@ -4,9 +4,7 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.feature.profile.api.ProfileNavKey -import com.team.prezel.feature.terms.api.TermsDocumentType import com.team.prezel.feature.terms.api.TermsNavKey -import com.team.prezel.feature.terms.impl.BuildConfig import com.team.prezel.feature.terms.impl.TermsScreen import com.team.prezel.feature.terms.impl.component.TermsDetailModal import dagger.Module @@ -23,11 +21,8 @@ internal fun EntryProviderScope.featureTermsEntryBuilder() { navigateBack = { navigator.goBack() }, - navigateToTermsOfServiceDetail = { - navigator.navigate(TermsNavKey.Detail(TermsDocumentType.TERMS_OF_SERVICE)) - }, - navigateToPrivacyPolicyDetail = { - navigator.navigate(TermsNavKey.Detail(TermsDocumentType.PRIVACY_POLICY)) + navigateToTermsDetail = { title, url -> + navigator.navigate(TermsNavKey.Detail(title = title, url = url)) }, navigateToProfile = { navigator.navigate(ProfileNavKey.Create) @@ -39,18 +34,13 @@ internal fun EntryProviderScope.featureTermsEntryBuilder() { val navigator = LocalNavigator.current TermsDetailModal( - url = key.document.toUrl(), + title = key.title, + url = key.url, onDismiss = { navigator.goBack() }, ) } } -private fun TermsDocumentType.toUrl(): String = - when (this) { - TermsDocumentType.TERMS_OF_SERVICE -> BuildConfig.TERMS_OF_SERVICE_URL - TermsDocumentType.PRIVACY_POLICY -> BuildConfig.PRIVACY_POLICY_URL - } - @Module @InstallIn(ActivityRetainedComponent::class) object FeatureTermsModule { diff --git a/Prezel/feature/terms/impl/src/main/res/values/strings.xml b/Prezel/feature/terms/impl/src/main/res/values/strings.xml index bdaa3420..618213f2 100644 --- a/Prezel/feature/terms/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/terms/impl/src/main/res/values/strings.xml @@ -5,22 +5,13 @@ 약관동의 전체 동의 - (필수) 이용약관 - (필수) 개인정보 정책 - (선택) 데이터 활용 동의 자세히 보기 다음 + 네트워크 연결을 확인한 뒤 다시 시도해 주세요. + 약관 정보를 불러오지 못했어요.\n잠시 후 다시 시도해 주세요. + 약관 정보를 불러오지 못했어요.\n잠시 후 다시 시도해 주세요. 약관 동의 정보를 다시 확인해 주세요. 네트워크 연결을 확인한 뒤 다시 시도해 주세요. 서버에 문제가 발생했어요.\n잠시 후 다시 시도해 주세요. 약관 동의에 문제가 발생했어요.\n잠시 후 다시 시도해 주세요. - - 본 약관은 서비스 이용과 관련한 기본적인 권리·의무 및 책임사항을 규정합니다. - - 서비스 제공을 위해 개인정보를 수집 · 이용합니다.\n - 발표 연습을 위한 음성 녹음 및 분석 데이터 처리 내용이 포함됩니다.\n\n - 수집 항목 : 계정 정보, 음성 녹음 파일, 음성 분석 결과, 발표 대본, 서비스 이용 기록 등\n\n - 수집 목적 : 발표 분석, 개인 맞춤 피드백 제공, 연습 기록 관리 - - 서비스 품질 향상 및 기능 개선을 위해 비식별 처리된 분석 데이터를 활용할 수 있습니다. diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index 27fbe390..caad1df7 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -72,6 +72,7 @@ ktorfit-ksp = { module = "de.jensklingenberg.ktorfit:ktorfit-ksp", version.ref = timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } orhanobut-logger = { group = "com.orhanobut", name = "logger", version.ref = "orhanobutLogger" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }