diff --git a/Prezel/.editorconfig b/Prezel/.editorconfig index 8f5a7cb2..4a97d403 100644 --- a/Prezel/.editorconfig +++ b/Prezel/.editorconfig @@ -16,3 +16,7 @@ ktlint_standard_backing-property-naming = disabled ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_function-signature = enabled ktlint_function_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 2 + +# Keep @Inject constructor on the same line (ktlint #2138) +# Related ktlint issue: https://github.com/pinterest/ktlint/issues/2138 +ktlint_standard_annotation = disabled diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/base/PrezelDropShadow.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/base/PrezelDropShadow.kt index b8b24ad2..9d2b479c 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/base/PrezelDropShadow.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/base/PrezelDropShadow.kt @@ -307,6 +307,16 @@ object PrezelDropShadowDefaults { override fun getShadow(): List = shadowList } + data class Custom( + override val borderRadius: Dp = 0.dp, + override val backgroundColor: Color = Color.Transparent, + val token: PrezelShadowToken, + ) : PrezelShadowStyle(borderRadius, backgroundColor) { + private val shadowList by lazy { listOf(token) } + + override fun getShadow(): List = shadowList + } + /** * 개별 그림자 레이어의 오프셋, blur, spread, 색상을 정의합니다. */ diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabSize.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabSize.kt new file mode 100644 index 00000000..1f33bad4 --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabSize.kt @@ -0,0 +1,3 @@ +package com.team.prezel.core.designsystem.component.navigations + +enum class PrezelTabSize { REGULAR, MEDIUM } diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt new file mode 100644 index 00000000..e5e79b5e --- /dev/null +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabs.kt @@ -0,0 +1,80 @@ +package com.team.prezel.core.designsystem.component.navigations + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.TabIndicatorScope +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.foundation.typography.PrezelTextStyles +import com.team.prezel.core.designsystem.theme.PrezelTheme +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun PrezelTabs( + tabs: ImmutableList, + pagerState: PagerState, + modifier: Modifier = Modifier, + size: PrezelTabSize = PrezelTabSize.REGULAR, + onClickTab: (index: Int) -> Unit, +) { + SecondaryTabRow( + selectedTabIndex = pagerState.currentPage, + modifier = modifier.fillMaxWidth(), + containerColor = Color.Transparent, + indicator = { PrezelTabIndicator(selectedTabIndex = pagerState.currentPage) }, + ) { + tabs.forEachIndexed { index, label -> + PrezelTab( + label = label, + selected = pagerState.currentPage == index, + size = size, + onClick = { onClickTab(index) }, + ) + } + } +} + +@Composable +private fun TabIndicatorScope.PrezelTabIndicator( + selectedTabIndex: Int, + modifier: Modifier = Modifier, +) { + Spacer( + modifier = modifier + .fillMaxWidth() + .height(2.dp) + .tabIndicatorOffset(selectedTabIndex = selectedTabIndex) + .background(PrezelTheme.colors.solidBlack), + ) +} + +@Composable +private fun PrezelTab( + label: String, + selected: Boolean, + size: PrezelTabSize, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Tab( + selected = selected, + onClick = onClick, + modifier = modifier.height(if (size == PrezelTabSize.REGULAR) 36.dp else 48.dp), + text = { + Text( + text = label, + style = if (size == PrezelTabSize.REGULAR) PrezelTextStyles.Body3Medium.toTextStyle() else PrezelTextStyles.Body2Bold.toTextStyle(), + ) + }, + selectedContentColor = PrezelTheme.colors.solidBlack, + unselectedContentColor = PrezelTheme.colors.textDisabled, + ) +} diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelTabs.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabsPager.kt similarity index 60% rename from Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelTabs.kt rename to Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabsPager.kt index 1743c7db..c9561915 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/PrezelTabs.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/navigations/PrezelTabsPager.kt @@ -1,44 +1,33 @@ -package com.team.prezel.core.designsystem.component +package com.team.prezel.core.designsystem.component.navigations -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.team.prezel.core.designsystem.foundation.typography.PrezelTextStyles import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.preview.PreviewDefaults import com.team.prezel.core.designsystem.preview.PreviewScaffold -import com.team.prezel.core.designsystem.theme.PrezelTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlin.math.abs -enum class PrezelTabSize { Small, Regular } - @Composable -fun PrezelTabs( +fun PrezelTabsPager( tabs: ImmutableList, pagerState: PagerState, modifier: Modifier = Modifier, - size: PrezelTabSize = PrezelTabSize.Regular, + size: PrezelTabSize = PrezelTabSize.REGULAR, userScrollEnabled: Boolean = true, content: @Composable (pageIndex: Int) -> Unit, ) { @@ -50,11 +39,11 @@ fun PrezelTabs( val scope = rememberCoroutineScope() Column(modifier = modifier.fillMaxSize()) { - PrezelTabsBar( + PrezelTabs( tabs = tabs, pagerState = pagerState, size = size, - onTabClick = { index -> + onClickTab = { index -> handleTabClick(scope, pagerState, index) }, ) @@ -67,60 +56,23 @@ fun PrezelTabs( } } -@Composable -private fun PrezelTabsBar( - tabs: ImmutableList, +private fun handleTabClick( + scope: CoroutineScope, pagerState: PagerState, - size: PrezelTabSize, - onTabClick: (index: Int) -> Unit, + target: Int, ) { - SecondaryTabRow( - selectedTabIndex = pagerState.currentPage, - modifier = Modifier.fillMaxWidth(), - containerColor = Color.Transparent, - indicator = { - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(2.dp) - .tabIndicatorOffset(selectedTabIndex = pagerState.currentPage) - .background(PrezelTheme.colors.solidBlack), - ) - }, - ) { - tabs.forEachIndexed { index, label -> - PrezelTabContent( - label = label, - selected = pagerState.currentPage == index, - size = size, - onClick = { onTabClick(index) }, - ) + scope.launch { + val current = pagerState.currentPage + val distance = abs(current - target) + + if (distance <= 1) { + pagerState.animateScrollToPage(target) + } else { + pagerState.scrollToPage(target) } } } -@Composable -private fun PrezelTabContent( - label: String, - selected: Boolean, - size: PrezelTabSize, - onClick: () -> Unit, -) { - Tab( - selected = selected, - onClick = onClick, - modifier = Modifier.height(if (size == PrezelTabSize.Small) 36.dp else 48.dp), - text = { - Text( - text = label, - style = if (size == PrezelTabSize.Small) PrezelTextStyles.Body3Medium.toTextStyle() else PrezelTextStyles.Body2Bold.toTextStyle(), - ) - }, - selectedContentColor = PrezelTheme.colors.solidBlack, - unselectedContentColor = PrezelTheme.colors.textDisabled, - ) -} - @Composable private fun PrezelTabsPager( pagerState: PagerState, @@ -136,23 +88,6 @@ private fun PrezelTabsPager( } } -private fun handleTabClick( - scope: CoroutineScope, - pagerState: PagerState, - target: Int, -) { - scope.launch { - val current = pagerState.currentPage - val distance = abs(current - target) - - if (distance <= 1) { - pagerState.animateScrollToPage(target) - } else { - pagerState.scrollToPage(target) - } - } -} - @BasicPreview @Composable private fun PrezelMediumTabPreview() { @@ -162,10 +97,10 @@ private fun PrezelMediumTabPreview() { PreviewScaffold( defaults = PreviewDefaults(screenPadding = PaddingValues(0.dp)), ) { - PrezelTabs( + PrezelTabsPager( tabs = tabs, pagerState = pagerState, - size = PrezelTabSize.Regular, + size = PrezelTabSize.MEDIUM, modifier = Modifier, ) { page -> Box( @@ -180,17 +115,17 @@ private fun PrezelMediumTabPreview() { @BasicPreview @Composable -private fun PrezelSmallTabPreview() { +private fun PrezelRegularTabPreview() { val tabs = persistentListOf("Label1", "Label2") val pagerState = rememberPagerState(initialPage = 0) { tabs.size } PreviewScaffold( defaults = PreviewDefaults(screenPadding = PaddingValues(0.dp)), ) { - PrezelTabs( + PrezelTabsPager( tabs = tabs, pagerState = pagerState, - size = PrezelTabSize.Small, + size = PrezelTabSize.REGULAR, ) { page -> Box( modifier = Modifier.fillMaxSize(), diff --git a/Prezel/core/model/.gitignore b/Prezel/core/model/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/Prezel/core/model/.gitignore @@ -0,0 +1 @@ +/build diff --git a/Prezel/core/model/build.gradle.kts b/Prezel/core/model/build.gradle.kts new file mode 100644 index 00000000..ec4f50d9 --- /dev/null +++ b/Prezel/core/model/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.prezel.jvm.library) +} + +dependencies { + implementation(libs.kotlinx.datetime) +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt new file mode 100644 index 00000000..0dfd03da --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Category.kt @@ -0,0 +1,8 @@ +package com.team.prezel.core.model.presentation + +enum class Category { + PERSUASION, + EVENT, + EDUCATION, + REPORT, +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Presentation.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Presentation.kt new file mode 100644 index 00000000..1ef76514 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/presentation/Presentation.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.model.presentation + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn +import kotlin.time.Clock + +data class Presentation( + val id: Long, + val category: Category, + val title: String, + val date: LocalDate, +) { + fun dDay(now: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())): Int = (date.toEpochDays() - now.toEpochDays()).toInt() +} diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/BaseViewModel.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/BaseViewModel.kt new file mode 100644 index 00000000..3c9cb2cb --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/BaseViewModel.kt @@ -0,0 +1,40 @@ +package com.team.prezel.core.ui + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update + +@Immutable +interface UiState + +interface UiIntent + +interface UiEffect + +abstract class BaseViewModel( + initialState: STATE, +) : ViewModel() { + private val _uiState = MutableStateFlow(initialState) + val uiState: StateFlow = _uiState + + private val _uiEffect = Channel() + val uiEffect: Flow = _uiEffect.receiveAsFlow() + + protected val currentState: STATE + get() = uiState.value + + protected fun updateState(reducer: STATE.() -> STATE) { + _uiState.update(reducer) + } + + protected suspend fun sendEffect(effect: EFFECT) { + _uiEffect.send(effect) + } + + abstract fun onIntent(intent: INTENT) +} diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/OnHeightChanged.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/OnHeightChanged.kt new file mode 100644 index 00000000..606f7070 --- /dev/null +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/OnHeightChanged.kt @@ -0,0 +1,16 @@ +package com.team.prezel.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + +@Composable +fun Modifier.onHeightChanged(onHeightChanged: (Dp) -> Unit): Modifier { + val density = LocalDensity.current + + return onSizeChanged { size -> + with(density) { onHeightChanged(size.height.toDp()) } + } +} diff --git a/Prezel/feature/home/impl/build.gradle.kts b/Prezel/feature/home/impl/build.gradle.kts index 2cc51c69..8e5ce3e3 100644 --- a/Prezel/feature/home/impl/build.gradle.kts +++ b/Prezel/feature/home/impl/build.gradle.kts @@ -7,5 +7,9 @@ android { } dependencies { + implementation(projects.coreModel) implementation(projects.featureHomeApi) + + implementation(libs.kotlinx.collections.immutable) + implementation(libs.kotlinx.datetime) } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt index 9b514f33..4a0c7c83 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.kt @@ -1,18 +1,298 @@ package com.team.prezel.feature.home.impl import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.modal.snackbar.showPrezelSnackbar +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.ui.LocalSnackbarHostState +import com.team.prezel.core.ui.onHeightChanged +import com.team.prezel.feature.home.impl.component.HomePageLayout +import com.team.prezel.feature.home.impl.component.body.EmptyPresentationSheet +import com.team.prezel.feature.home.impl.component.body.PresentationSheet +import com.team.prezel.feature.home.impl.component.head.HomeHeadSection +import com.team.prezel.feature.home.impl.component.title.EmptyPresentationHero +import com.team.prezel.feature.home.impl.component.title.PresentationHero +import com.team.prezel.feature.home.impl.contract.HomeUiEffect +import com.team.prezel.feature.home.impl.contract.HomeUiIntent +import com.team.prezel.feature.home.impl.contract.HomeUiState +import com.team.prezel.feature.home.impl.model.HomeUiMessage +import com.team.prezel.feature.home.impl.model.PresentationUiModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate @Composable -fun HomeScreen(modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text("Home") +internal fun HomeScreen( + modifier: Modifier = Modifier, + viewModel: HomeViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(0) { uiState.presentationCount() } + val snackbarHostState = LocalSnackbarHostState.current + val resources = LocalResources.current + + LaunchedEffect(Unit) { + viewModel.onIntent(HomeUiIntent.FetchData) + + viewModel.uiEffect.collect { effect -> + when (effect) { + is HomeUiEffect.ShowMessage -> { + val resId = when (effect.message) { + HomeUiMessage.FETCH_DATA_FAILED -> R.string.feature_home_impl_fetch_data_failed + } + snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) + } + } + } + } + + HomeScreen( + uiState = uiState, + pagerState = pagerState, + onClickAddPresentation = { }, + onClickAnalyzePresentation = { }, + onClickWriteFeedback = { }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeScreen( + uiState: HomeUiState, + pagerState: PagerState, + onClickAddPresentation: () -> Unit, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val maxScreenHeight = maxHeight + var headerHeight by remember { mutableStateOf(0.dp) } + + Box(modifier = Modifier.fillMaxSize()) { + HomeContent( + uiState = uiState, + pagerState = pagerState, + maxHeight = maxScreenHeight, + headerHeight = headerHeight, + onClickAddPresentation = onClickAddPresentation, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + ) + + HomeHeadSection( + uiState = uiState, + pagerState = pagerState, + onClickTab = { pageIndex -> scope.launch { pagerState.scrollToPage(pageIndex) } }, + modifier = Modifier.onHeightChanged { newHeight -> headerHeight = newHeight }, + ) + } + } +} + +@Composable +private fun HomeContent( + uiState: HomeUiState, + pagerState: PagerState, + maxHeight: Dp, + headerHeight: Dp, + onClickAddPresentation: () -> Unit, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, +) { + when (uiState) { + HomeUiState.Loading -> Unit + is HomeUiState.Empty -> { + HomeEmptyContent( + maxHeight = maxHeight, + headerHeight = headerHeight, + uiState = uiState, + onClickAddPresentation = onClickAddPresentation, + ) + } + + is HomeUiState.SingleContent -> { + HomeSingleContent( + uiState = uiState, + maxHeight = maxHeight, + headerHeight = headerHeight, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + ) + } + + is HomeUiState.MultipleContent -> { + HomeMultipleContent( + uiState = uiState, + pagerState = pagerState, + maxHeight = maxHeight, + headerHeight = headerHeight, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + ) + } + } +} + +@Composable +private fun HomeEmptyContent( + maxHeight: Dp, + headerHeight: Dp, + uiState: HomeUiState.Empty, + onClickAddPresentation: () -> Unit, +) { + HomePageLayout( + maxHeight = maxHeight, + headerHeight = headerHeight, + sheetContent = { EmptyPresentationSheet() }, + heroContent = { + EmptyPresentationHero( + nickname = uiState.nickname, + onClickAddPresentation = onClickAddPresentation, + ) + }, + ) +} + +@Composable +private fun HomeSingleContent( + uiState: HomeUiState.SingleContent, + maxHeight: Dp, + headerHeight: Dp, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, +) { + val presentation = uiState.presentation + + HomePageLayout( + maxHeight = maxHeight, + headerHeight = headerHeight, + sheetContent = { PresentationSheet(practiceCount = presentation.practiceCount) }, + heroContent = { + PresentationHero( + presentation = presentation, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + ) + }, + ) +} + +@Composable +private fun HomeMultipleContent( + uiState: HomeUiState.MultipleContent, + pagerState: PagerState, + maxHeight: Dp, + headerHeight: Dp, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, +) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + overscrollEffect = null, + userScrollEnabled = false, + key = { pageIndex -> uiState.presentations[pageIndex].id }, + ) { pageIndex -> + val presentation = uiState.presentations[pageIndex] + + HomePageLayout( + maxHeight = maxHeight, + headerHeight = headerHeight, + sheetContent = { PresentationSheet(practiceCount = presentation.practiceCount) }, + heroContent = { + PresentationHero( + presentation = presentation, + onClickAnalyzePresentation = onClickAnalyzePresentation, + onClickWriteFeedback = onClickWriteFeedback, + ) + }, + ) + } +} + +@BasicPreview +@Composable +private fun HomeScreenEmptyPreview() { + val uiState = HomeUiState.Empty(nickname = "프레즐") + PrezelTheme { + HomeScreen( + uiState = uiState, + pagerState = rememberPagerState(0) { uiState.presentationCount() }, + onClickAddPresentation = { }, + onClickAnalyzePresentation = { }, + onClickWriteFeedback = { }, + ) + } +} + +@BasicPreview +@Composable +private fun HomeScreenSinglePreview() { + val uiState = HomeUiState.SingleContent( + presentation = PresentationUiModel( + id = 1L, + category = Category.PERSUASION, + title = "날짜 지난 발표제목", + date = LocalDate(2026, 4, 3), + dDay = -1, + ), + ) + PrezelTheme { + HomeScreen( + uiState = uiState, + pagerState = rememberPagerState(0) { uiState.presentationCount() }, + onClickAddPresentation = { }, + onClickAnalyzePresentation = { }, + onClickWriteFeedback = { }, + ) + } +} + +@BasicPreview +@Composable +private fun HomeScreenMultiplePreview() { + val uiState = HomeUiState.MultipleContent( + List(3) { index -> + PresentationUiModel( + id = index.toLong(), + category = Category.EDUCATION, + title = "공백포함둘에서열글자", + date = LocalDate(2026, 4, 10 + index), + dDay = index, + ) + }.toPersistentList(), + ) + PrezelTheme { + HomeScreen( + uiState = uiState, + pagerState = rememberPagerState(0) { uiState.presentationCount() }, + onClickAddPresentation = { }, + onClickAnalyzePresentation = { }, + onClickWriteFeedback = { }, + ) } } diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeUiState.kt deleted file mode 100644 index 1127c7f3..00000000 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.feature.home.impl - -sealed interface HomeUiState { - data object Loading : HomeUiState - - data object LoadFailed : HomeUiState -} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt index 808388ee..d4b2f389 100644 --- a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeViewModel.kt @@ -1,10 +1,54 @@ package com.team.prezel.feature.home.impl -import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Presentation +import com.team.prezel.core.ui.BaseViewModel +import com.team.prezel.feature.home.impl.contract.HomeUiEffect +import com.team.prezel.feature.home.impl.contract.HomeUiIntent +import com.team.prezel.feature.home.impl.contract.HomeUiState +import com.team.prezel.feature.home.impl.model.PresentationUiModel +import com.team.prezel.feature.home.impl.model.PresentationUiModel.Companion.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate import javax.inject.Inject @HiltViewModel -class HomeViewModel - @Inject - constructor() : ViewModel() +internal class HomeViewModel @Inject constructor() : BaseViewModel(HomeUiState.Loading) { + override fun onIntent(intent: HomeUiIntent) { + when (intent) { + HomeUiIntent.FetchData -> fetchData() + } + } + + private fun fetchData() { + viewModelScope.launch { + val nickname = "프레즐" + val presentations = getPresentations() + + updateState { + HomeUiState.from( + presentations = presentations, + nickname = nickname, + ) + } + } + } + + private fun getPresentations(): List = + listOf( + Presentation( + id = 1L, + category = Category.PERSUASION, + title = "신규 서비스 제안 발표", + date = LocalDate(2026, 4, 10), + ), + Presentation( + id = 2L, + category = Category.REPORT, + title = "주간 업무 공유", + date = LocalDate(2026, 4, 12), + ), + ).map { presentation -> presentation.toUiModel() } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt new file mode 100644 index 00000000..04badf22 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/HomePageLayout.kt @@ -0,0 +1,123 @@ +package com.team.prezel.feature.home.impl.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.coerceAtLeast +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.onHeightChanged +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.component.body.HomeBottomSheetContent +import com.team.prezel.feature.home.impl.component.body.HomeBottomSheetTitle +import com.team.prezel.feature.home.impl.component.title.HomeHeroLayout + +private data class HomeBottomSheetLayoutState( + val sheetPeekHeight: Dp, + val updateTitleSectionHeight: (Dp) -> Unit, +) + +private val HomeBottomSheetShadowTopInset = 18.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HomePageLayout( + maxHeight: Dp, + headerHeight: Dp, + modifier: Modifier = Modifier, + sheetContent: @Composable ColumnScope.() -> Unit, + heroContent: @Composable BoxScope.() -> Unit, +) { + val layoutState = rememberHomeBottomSheetLayoutState( + maxHeight = maxHeight, + headerHeight = headerHeight, + ) + + BottomSheetScaffold( + modifier = modifier.fillMaxSize(), + sheetPeekHeight = layoutState.sheetPeekHeight, + sheetShape = PrezelTheme.shapes.V16, + sheetContent = { + Box(modifier = Modifier.padding(top = HomeBottomSheetShadowTopInset)) { + with(this@BottomSheetScaffold) { + sheetContent() + } + } + }, + sheetDragHandle = null, + containerColor = Color.Transparent, + sheetContainerColor = Color.Transparent, + sheetShadowElevation = 0.dp, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = headerHeight), + ) { + Box( + modifier = Modifier.onHeightChanged(layoutState.updateTitleSectionHeight), + content = heroContent, + ) + } + } +} + +@Composable +private fun rememberHomeBottomSheetLayoutState( + maxHeight: Dp, + headerHeight: Dp, +): HomeBottomSheetLayoutState { + var titleSectionHeight by remember { mutableStateOf(0.dp) } + val sheetPeekHeight = remember(maxHeight, headerHeight, titleSectionHeight) { + (maxHeight - headerHeight - titleSectionHeight + HomeBottomSheetShadowTopInset).coerceAtLeast(0.dp) + } + + return remember(titleSectionHeight, sheetPeekHeight) { + HomeBottomSheetLayoutState( + sheetPeekHeight = sheetPeekHeight, + updateTitleSectionHeight = { newHeight -> titleSectionHeight = newHeight }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@BasicPreview +@Composable +private fun HomeBodySectionPreview() { + PrezelTheme { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + HomePageLayout( + maxHeight = maxHeight, + headerHeight = 0.dp, + sheetContent = { + HomeBottomSheetContent( + contentPadding = PaddingValues(vertical = 32.dp, horizontal = 20.dp), + ) { + HomeBottomSheetTitle(title = "지금부터 연습해보세요") + } + }, + heroContent = { + HomeHeroLayout( + backgroundResId = R.drawable.feature_home_impl_section_title_empty, + ) { } + }, + ) + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt new file mode 100644 index 00000000..30b5be30 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.kt @@ -0,0 +1,37 @@ +package com.team.prezel.feature.home.impl.component.body + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.home.impl.R + +@Composable +internal fun EmptyPresentationSheet(modifier: Modifier = Modifier) { + HomeBottomSheetContent( + modifier = modifier, + contentPadding = PaddingValues(vertical = PrezelTheme.spacing.V32, horizontal = PrezelTheme.spacing.V20), + ) { + HomeBottomSheetTitle(title = stringResource(R.string.feature_home_impl_bottom_sheet_empty_title)) + } +} + +@BasicPreview +@Composable +private fun EmptyPresentationContentPreview() { + PrezelTheme { + Box( + modifier = Modifier + .height(100.dp) + .padding(top = 16.dp), + ) { + EmptyPresentationSheet() + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt new file mode 100644 index 00000000..dd1173fb --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetContent.kt @@ -0,0 +1,86 @@ +package com.team.prezel.feature.home.impl.component.body + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.graphics.Color +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.base.PrezelDropShadowDefaults +import com.team.prezel.core.designsystem.component.base.prezelDropShadow +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun HomeBottomSheetContent( + modifier: Modifier = Modifier, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + contentPadding: PaddingValues = PaddingValues(vertical = PrezelTheme.spacing.V32), + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .prezelDropShadow(style = bottomSheetShadowStyle()) + .verticalScroll(rememberScrollState()) + .padding(contentPadding), + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + content = content, + ) +} + +@Composable +private fun bottomSheetShadowStyle() = + PrezelDropShadowDefaults.Custom( + borderRadius = PrezelTheme.radius.V16, + backgroundColor = PrezelTheme.colors.bgRegular, + token = PrezelDropShadowDefaults.PrezelShadowToken( + offsetX = 0.dp, + offsetY = (-6).dp, + blurRadius = 12.dp, + spreadRadius = 0.dp, + color = Color(0xFFF5F6F7), + ), + ) + +@BasicPreview +@Composable +private fun HomeBottomSheetContentPreview() { + PrezelTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp) + .padding(top = 16.dp), + ) { + HomeBottomSheetContent( + contentPadding = PaddingValues( + vertical = PrezelTheme.spacing.V32, + horizontal = PrezelTheme.spacing.V20, + ), + verticalArrangement = Arrangement.spacedBy(PrezelTheme.spacing.V12), + ) { + HomeBottomSheetTitle(title = "지금부터 연습해보세요") + repeat(3) { index -> + Text( + text = "${index + 1}. 발표 흐름을 다시 점검해보세요", + color = PrezelTheme.colors.textRegular, + style = PrezelTheme.typography.body3Regular, + ) + } + } + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt new file mode 100644 index 00000000..9b8aec7c --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/HomeBottomSheetTitle.kt @@ -0,0 +1,34 @@ +package com.team.prezel.feature.home.impl.component.body + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun HomeBottomSheetTitle( + title: String, + modifier: Modifier = Modifier, +) { + Text( + modifier = modifier.fillMaxWidth(), + text = title, + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.body2Bold, + ) +} + +@BasicPreview +@Composable +private fun HomeBottomSheetTitlePreview() { + PrezelTheme { + Box(modifier = Modifier.padding(8.dp)) { + HomeBottomSheetTitle(title = "프레젤 홈 바텀 시트 타이틀") + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt new file mode 100644 index 00000000..dd8f59e3 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.kt @@ -0,0 +1,53 @@ +package com.team.prezel.feature.home.impl.component.body + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.model.PresentationUiModel +import kotlinx.datetime.LocalDate + +@Composable +internal fun PresentationSheet( + practiceCount: Int, + modifier: Modifier = Modifier, +) { + val itemModifier = Modifier.padding(horizontal = PrezelTheme.spacing.V20) + + HomeBottomSheetContent(modifier = modifier) { + HomeBottomSheetTitle( + title = stringResource(R.string.feature_home_impl_bottom_sheet_content_title, practiceCount), + modifier = itemModifier, + ) + } +} + +@BasicPreview +@Composable +private fun PresentationContentPreview() { + PrezelTheme { + val presentation = PresentationUiModel( + id = 1L, + category = Category.PERSUASION, + title = "설득하는 발표", + date = LocalDate(2026, 10, 1), + dDay = 3, + practiceCount = 5, + ) + + Box( + modifier = Modifier + .height(100.dp) + .padding(top = 16.dp), + ) { + PresentationSheet(practiceCount = presentation.practiceCount) + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt new file mode 100644 index 00000000..36e53fc3 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/head/HomeHeadSection.kt @@ -0,0 +1,106 @@ +package com.team.prezel.feature.home.impl.component.head + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.component.PrezelTopAppBar +import com.team.prezel.core.designsystem.component.navigations.PrezelTabSize +import com.team.prezel.core.designsystem.component.navigations.PrezelTabs +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.contract.HomeUiState +import com.team.prezel.feature.home.impl.model.PresentationUiModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.datetime.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HomeHeadSection( + uiState: HomeUiState, + pagerState: PagerState, + onClickTab: (pageIndex: Int) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .background(PrezelTheme.colors.bgRegular), + ) { + PrezelTopAppBar( + title = { Text(text = stringResource(R.string.feature_home_impl_title)) }, + ) + + if (uiState is HomeUiState.MultipleContent) { + PrezelTabs( + pagerState = pagerState, + tabs = uiState.dDayLabels, + size = PrezelTabSize.REGULAR, + onClickTab = onClickTab, + ) + } + } +} + +@BasicPreview +@Composable +private fun HomeHeadSectionSinglePreview() { + val uiState = HomeUiState.SingleContent( + presentation = PresentationUiModel( + id = 1L, + category = Category.EDUCATION, + title = "발표 1", + date = LocalDate(2024, 1, 1), + dDay = 0, + ), + ) + val pagerState = rememberPagerState(0) { uiState.presentationCount() } + + PrezelTheme { + HomeHeadSection( + uiState = uiState, + pagerState = pagerState, + onClickTab = {}, + ) + } +} + +@BasicPreview +@Composable +private fun HomeHeadSectionMultiplePreview() { + val uiState = HomeUiState.MultipleContent( + presentations = listOf( + PresentationUiModel( + id = 1L, + category = Category.EDUCATION, + title = "발표 1", + date = LocalDate(2024, 1, 1), + dDay = 0, + ), + PresentationUiModel( + id = 2L, + category = Category.EVENT, + title = "발표 2", + date = LocalDate(2024, 1, 2), + dDay = 1, + ), + ).toImmutableList(), + ) + val pagerState = rememberPagerState(0) { uiState.presentationCount() } + + PrezelTheme { + HomeHeadSection( + uiState = uiState, + pagerState = pagerState, + {}, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt new file mode 100644 index 00000000..f6d3563c --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/EmptyPresentationHero.kt @@ -0,0 +1,53 @@ +package com.team.prezel.feature.home.impl.component.title + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.home.impl.R + +@Composable +internal fun EmptyPresentationHero( + nickname: String, + onClickAddPresentation: () -> Unit, + modifier: Modifier = Modifier, +) { + HomeHeroLayout( + backgroundResId = R.drawable.feature_home_impl_section_title_empty, + modifier = modifier, + ) { + Text( + text = stringResource(R.string.feature_home_impl_empty_greeting, nickname), + color = PrezelTheme.colors.textMedium, + style = PrezelTheme.typography.title1Medium, + ) + Text( + text = stringResource(R.string.feature_home_impl_empty_subtitle), + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.title1Bold, + ) + Spacer(modifier = Modifier.weight(1f)) + PracticeActionCard( + title = stringResource(R.string.feature_home_impl_add_presentation_title), + actionText = stringResource(R.string.feature_home_impl_add_presentation_action), + titleColor = PrezelTheme.colors.interactiveRegular, + modifier = Modifier.fillMaxWidth(), + onClick = onClickAddPresentation, + ) + } +} + +@BasicPreview +@Composable +private fun EmptyTitleSectionPreview() { + PrezelTheme { + EmptyPresentationHero( + nickname = "프레즐", + onClickAddPresentation = {}, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt new file mode 100644 index 00000000..2f225e73 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/HomeHeroLayout.kt @@ -0,0 +1,51 @@ +package com.team.prezel.feature.home.impl.component.title + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.feature.home.impl.R + +@Composable +internal fun HomeHeroLayout( + @DrawableRes backgroundResId: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .aspectRatio(4f / 3f) + .paint( + painter = painterResource(id = backgroundResId), + contentScale = ContentScale.FillWidth, + ).padding(all = PrezelTheme.spacing.V20), + content = content, + ) +} + +@BasicPreview +@Composable +private fun HomeHeroLayoutPreview() { + PrezelTheme { + HomeHeroLayout( + backgroundResId = R.drawable.feature_home_impl_section_title_empty, + ) { + Text( + text = "Home Title Section", + style = PrezelTheme.typography.title1Bold, + color = PrezelTheme.colors.textRegular, + ) + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt new file mode 100644 index 00000000..6619074d --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PracticeActionCard.kt @@ -0,0 +1,89 @@ +package com.team.prezel.feature.home.impl.component.title + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.team.prezel.core.designsystem.component.base.PrezelTouchArea +import com.team.prezel.core.designsystem.icon.PrezelIcons +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme + +@Composable +internal fun PracticeActionCard( + title: String, + actionText: String, + titleColor: Color, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + PrezelTouchArea( + onClick = onClick, + shape = PrezelTheme.shapes.V8, + ) { + Column( + modifier = modifier + .fillMaxWidth() + .clip(shape = PrezelTheme.shapes.V8) + .border( + width = PrezelTheme.stroke.V1, + shape = PrezelTheme.shapes.V8, + color = PrezelTheme.colors.borderSmall, + ).background(color = PrezelTheme.colors.bgRegular) + .padding(horizontal = PrezelTheme.spacing.V16, vertical = PrezelTheme.spacing.V12), + ) { + Text( + text = title, + color = titleColor, + style = PrezelTheme.typography.body2Bold, + ) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V6)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = actionText, + color = PrezelTheme.colors.textRegular, + style = PrezelTheme.typography.caption1Regular, + ) + + Icon( + painter = painterResource(PrezelIcons.ChevronRight), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = PrezelTheme.colors.iconRegular, + ) + } + } + } +} + +@BasicPreview +@Composable +private fun PracticeActionCardPreview() { + PrezelTheme { + Box(modifier = Modifier.padding(8.dp)) { + PracticeActionCard( + title = "연습하기", + actionText = "지금 바로 시작하기", + titleColor = PrezelTheme.colors.interactiveRegular, + onClick = {}, + ) + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt new file mode 100644 index 00000000..ce8d5ade --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/title/PresentationHero.kt @@ -0,0 +1,155 @@ +package com.team.prezel.feature.home.impl.component.title + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +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 com.team.prezel.core.designsystem.component.chip.PrezelChip +import com.team.prezel.core.designsystem.component.chip.config.PrezelChipDefaults +import com.team.prezel.core.designsystem.preview.BasicPreview +import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.feature.home.impl.R +import com.team.prezel.feature.home.impl.model.PresentationUiModel +import kotlinx.datetime.LocalDate +import kotlinx.datetime.number + +@Composable +internal fun PresentationHero( + presentation: PresentationUiModel, + onClickAnalyzePresentation: (PresentationUiModel) -> Unit, + onClickWriteFeedback: (PresentationUiModel) -> Unit, + modifier: Modifier = Modifier, +) { + HomeHeroLayout( + backgroundResId = presentation.category.backgroundResId(), + modifier = modifier, + ) { + PrezelChip( + text = stringResource(id = presentation.category.labelResId()), + config = PrezelChipDefaults.getDefault( + iconOnly = false, + containerColor = PrezelTheme.colors.bgRegular, + textColor = PrezelTheme.colors.interactiveRegular, + ), + ) + + Spacer(modifier = Modifier.weight(1f)) + + HomePresentationDate(date = presentation.date) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V8)) + + HomePresentationTitleRow(presentation = presentation) + + Spacer(modifier = Modifier.height(PrezelTheme.spacing.V12)) + + PracticeActionCard( + title = if (presentation.isPastPresentation) { + stringResource(R.string.feature_home_impl_write_feedback_title, presentation.title) + } else { + stringResource(R.string.feature_home_impl_analyze_presentation_title) + }, + actionText = if (presentation.isPastPresentation) { + stringResource(R.string.feature_home_impl_write_feedback_action) + } else { + stringResource(R.string.feature_home_impl_analyze_presentation_action) + }, + titleColor = PrezelTheme.colors.textMedium, + onClick = { + if (presentation.isPastPresentation) onClickWriteFeedback(presentation) else onClickAnalyzePresentation(presentation) + }, + ) + } +} + +@Composable +private fun HomePresentationDate(date: LocalDate) { + Text( + text = stringResource( + R.string.feature_home_impl_presentation_date, + date.year, + date.month.number, + date.day, + ), + color = PrezelTheme.colors.textRegular, + style = PrezelTheme.typography.body3Regular, + ) +} + +@Composable +private fun HomePresentationTitleRow(presentation: PresentationUiModel) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = presentation.title, + modifier = Modifier.weight(1f), + color = PrezelTheme.colors.textLarge, + style = PrezelTheme.typography.title1Bold, + ) + Text( + text = presentation.dDayLabel, + color = PrezelTheme.colors.interactiveRegular, + style = PrezelTheme.typography.title1ExtraBold, + ) + } +} + +@StringRes +private fun Category.labelResId(): Int = + when (this) { + Category.PERSUASION -> R.string.feature_home_impl_category_persuasion + Category.EVENT -> R.string.feature_home_impl_category_event + Category.EDUCATION -> R.string.feature_home_impl_category_education + Category.REPORT -> R.string.feature_home_impl_category_report + } + +@DrawableRes +private fun Category.backgroundResId(): Int = + when (this) { + Category.PERSUASION -> R.drawable.feature_home_impl_section_title_hand + Category.EVENT -> R.drawable.feature_home_impl_section_title_event + Category.EDUCATION -> R.drawable.feature_home_impl_section_title_college + Category.REPORT -> R.drawable.feature_home_impl_section_title_company + } + +@BasicPreview +@Composable +private fun HomePresentationPagePreview() { + PrezelTheme { + PresentationHero( + presentation = PresentationUiModel( + id = 1L, + category = Category.PERSUASION, + title = "설득하는 발표", + date = LocalDate(2026, 10, 1), + dDay = 3, + ), + onClickAnalyzePresentation = {}, + onClickWriteFeedback = {}, + ) + } +} + +@BasicPreview +@Composable +private fun HomePresentationPagePastPreview() { + PrezelTheme { + PresentationHero( + presentation = PresentationUiModel( + id = 2L, + category = Category.EDUCATION, + title = "교육 발표", + date = LocalDate(2026, 9, 20), + dDay = -5, + ), + onClickAnalyzePresentation = {}, + onClickWriteFeedback = {}, + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt new file mode 100644 index 00000000..edfcdd03 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiEffect.kt @@ -0,0 +1,10 @@ +package com.team.prezel.feature.home.impl.contract + +import com.team.prezel.core.ui.UiEffect +import com.team.prezel.feature.home.impl.model.HomeUiMessage + +internal sealed interface HomeUiEffect : UiEffect { + data class ShowMessage( + val message: HomeUiMessage, + ) : HomeUiEffect +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt new file mode 100644 index 00000000..ae9e7977 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiIntent.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.home.impl.contract + +import com.team.prezel.core.ui.UiIntent + +internal sealed interface HomeUiIntent : UiIntent { + data object FetchData : HomeUiIntent +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt new file mode 100644 index 00000000..2a3d5f8f --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/contract/HomeUiState.kt @@ -0,0 +1,46 @@ +package com.team.prezel.feature.home.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.UiState +import com.team.prezel.feature.home.impl.model.PresentationUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@Immutable +internal sealed interface HomeUiState : UiState { + data object Loading : HomeUiState + + data class Empty( + val nickname: String, + ) : HomeUiState + + data class SingleContent( + val presentation: PresentationUiModel, + ) : HomeUiState + + data class MultipleContent( + val presentations: ImmutableList, + ) : HomeUiState { + val dDayLabels: ImmutableList = presentations.map(PresentationUiModel::dDayLabel).toImmutableList() + } + + fun presentationCount(): Int = + when (this) { + Loading -> 0 + is Empty -> 1 + is SingleContent -> 1 + is MultipleContent -> presentations.size + } + + companion object { + fun from( + presentations: List, + nickname: String, + ): HomeUiState = + when (presentations.size) { + 0 -> Empty(nickname = nickname) + 1 -> SingleContent(presentation = presentations.first()) + else -> MultipleContent(presentations = presentations.toImmutableList()) + } + } +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt new file mode 100644 index 00000000..5cd796f8 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/HomeUiMessage.kt @@ -0,0 +1,5 @@ +package com.team.prezel.feature.home.impl.model + +enum class HomeUiMessage { + FETCH_DATA_FAILED, +} diff --git a/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt new file mode 100644 index 00000000..5d7d055e --- /dev/null +++ b/Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/model/PresentationUiModel.kt @@ -0,0 +1,38 @@ +package com.team.prezel.feature.home.impl.model + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.model.presentation.Category +import com.team.prezel.core.model.presentation.Presentation +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn +import kotlin.time.Clock + +@Immutable +internal data class PresentationUiModel( + val id: Long, + val category: Category, + val title: String, + val date: LocalDate, + val dDay: Int, + val practiceCount: Int = 0, +) { + val isPastPresentation: Boolean = dDay < 0 + + val dDayLabel: String = when (dDay) { + 0 -> "D-Day" + in Int.MIN_VALUE..-1 -> "D+${-dDay}" + else -> "D-$dDay" + } + + companion object { + fun Presentation.toUiModel(now: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())): PresentationUiModel = + PresentationUiModel( + id = id, + category = category, + title = title, + date = date, + dDay = dDay(now = now), + ) + } +} diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_college.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_college.xml new file mode 100644 index 00000000..b709e8a7 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_college.xml @@ -0,0 +1,436 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_company.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_company.xml new file mode 100644 index 00000000..919e2500 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_company.xml @@ -0,0 +1,321 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_empty.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_empty.xml new file mode 100644 index 00000000..51f7f5fa --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_empty.xml @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_event.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_event.xml new file mode 100644 index 00000000..20056752 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_event.xml @@ -0,0 +1,599 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_hand.xml b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_hand.xml new file mode 100644 index 00000000..59945db1 --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/drawable/feature_home_impl_section_title_hand.xml @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prezel/feature/home/impl/src/main/res/values/strings.xml b/Prezel/feature/home/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..140c2c8b --- /dev/null +++ b/Prezel/feature/home/impl/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + + 지금부터 연습해보세요 + 지금까지 %1$d번 연습했어요 + 안녕하세요 %1$s님! + 어떤 발표를 앞두고 있나요? + %1$d년 %2$02d월 %3$02d일 + 발표 준비를 시작해볼까요? + 발표 추가하기 + 충분히 연습했는지 확인해볼까요? + 발표 분석하기 + \'%1$s\'는 어떠셨나요? + 피드백 작성하기 + + 설득·제안 + 행사·공개 + 학술·교육 + 업무·보고 + + + 데이터를 불러오지 못했습니다. + diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index 11cf9fb5..22418472 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -1,78 +1,58 @@ package com.team.prezel.feature.login.impl.landing -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult +import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.feature.login.impl.BuildConfig import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent import com.team.prezel.feature.login.impl.landing.contract.LoginUiState import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -internal class LoginViewModel - @Inject - constructor() : ViewModel() { - private val _uiState = MutableStateFlow(LoginUiState()) - val uiState: StateFlow = _uiState - private val currentState: LoginUiState - get() = uiState.value - - private val _uiEffect = Channel() - val uiEffect: Flow = _uiEffect.receiveAsFlow() - - fun onIntent(intent: LoginUiIntent) { - when (intent) { - is LoginUiIntent.OnClickLogin -> handleClickLogin(provider = intent.provider) - is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) - } - } - - private fun update(reducer: LoginUiState.() -> LoginUiState) { - _uiState.update(reducer) +internal class LoginViewModel @Inject constructor() : BaseViewModel(LoginUiState()) { + override fun onIntent(intent: LoginUiIntent) { + when (intent) { + is LoginUiIntent.OnClickLogin -> handleClickLogin(provider = intent.provider) + is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) } + } - private fun handleClickLogin(provider: AuthProvider) { - if (currentState.isLoading) return + private fun handleClickLogin(provider: AuthProvider) { + if (currentState.isLoading) return - viewModelScope.launch { - update { copy(isLoading = true) } + viewModelScope.launch { + updateState { copy(isLoading = true) } - // todo: MVP 개발 완료 후 해당 조건 제거 - if (BuildConfig.DEBUG) { - update { copy(isLoading = false) } - _uiEffect.send(LoginUiEffect.NavigateToTerms) - } else { - _uiEffect.send(LoginUiEffect.LaunchLogin(provider = provider)) - } + // todo: MVP 개발 완료 후 해당 조건 제거 + if (BuildConfig.DEBUG) { + updateState { copy(isLoading = false) } + sendEffect(LoginUiEffect.NavigateToTerms) + } else { + sendEffect(LoginUiEffect.LaunchLogin(provider = provider)) } } + } - private fun handleLoginResult(result: AuthResult) { - viewModelScope.launch { - update { copy(isLoading = false) } + private fun handleLoginResult(result: AuthResult) { + viewModelScope.launch { + updateState { copy(isLoading = false) } - when (result) { - AuthResult.Success -> _uiEffect.send(LoginUiEffect.NavigateToTerms) - AuthResult.Cancelled -> _uiEffect.send(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) - is AuthResult.Failure -> _uiEffect.send(LoginUiEffect.ShowMessage(result.toUiMessage())) - } + when (result) { + AuthResult.Success -> sendEffect(LoginUiEffect.NavigateToTerms) + AuthResult.Cancelled -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) + is AuthResult.Failure -> sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) } } - - private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = - when (this) { - AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited - AuthResult.Failure.Unknown -> LoginUiMessage.LoginFailedUnknown - } } + + private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = + when (this) { + AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited + AuthResult.Failure.Unknown -> LoginUiMessage.LoginFailedUnknown + } +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index 2176088e..cfc5af93 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -1,9 +1,10 @@ package com.team.prezel.feature.login.impl.landing.contract import com.team.prezel.core.auth.model.AuthProvider +import com.team.prezel.core.ui.UiEffect import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage -internal sealed interface LoginUiEffect { +internal sealed interface LoginUiEffect : UiEffect { data class LaunchLogin( val provider: AuthProvider, ) : LoginUiEffect diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt index 17c4d475..c0d8f2fb 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt @@ -2,8 +2,9 @@ package com.team.prezel.feature.login.impl.landing.contract import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult +import com.team.prezel.core.ui.UiIntent -internal sealed interface LoginUiIntent { +internal sealed interface LoginUiIntent : UiIntent { data class OnClickLogin( val provider: AuthProvider, ) : LoginUiIntent diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt index fbc8edc2..0a7c3b41 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiState.kt @@ -1,8 +1,9 @@ package com.team.prezel.feature.login.impl.landing.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.UiState @Immutable internal data class LoginUiState( val isLoading: Boolean = false, -) +) : UiState diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt index 0d838b3b..44f14895 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/TermsViewModel.kt @@ -1,74 +1,54 @@ package com.team.prezel.feature.login.impl.terms -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.team.prezel.core.ui.BaseViewModel import com.team.prezel.feature.login.impl.terms.contract.TermsUiEffect import com.team.prezel.feature.login.impl.terms.contract.TermsUiIntent import com.team.prezel.feature.login.impl.terms.contract.TermsUiState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -internal class TermsViewModel - @Inject - constructor() : ViewModel() { - private val _uiState = MutableStateFlow(TermsUiState()) - val uiState: StateFlow = _uiState - private val currentState: TermsUiState - get() = uiState.value - - private val _uiEffect = Channel() - val uiEffect: Flow = _uiEffect.receiveAsFlow() - - fun onIntent(intent: TermsUiIntent) { - when (intent) { - TermsUiIntent.ToggleAll -> toggleAll() - TermsUiIntent.ToggleTermsOfService -> toggleTermsOfService() - TermsUiIntent.TogglePrivacyPolicy -> togglePrivacyPolicy() - TermsUiIntent.ToggleMarketingConsent -> toggleMarketingConsent() - TermsUiIntent.ClickContinue -> handleClickContinue() - } - } - - private fun update(reducer: TermsUiState.() -> TermsUiState) { - _uiState.update(reducer) +internal class TermsViewModel @Inject constructor() : BaseViewModel(TermsUiState()) { + override fun onIntent(intent: TermsUiIntent) { + when (intent) { + TermsUiIntent.ToggleAll -> toggleAll() + TermsUiIntent.ToggleTermsOfService -> toggleTermsOfService() + TermsUiIntent.TogglePrivacyPolicy -> togglePrivacyPolicy() + TermsUiIntent.ToggleMarketingConsent -> toggleMarketingConsent() + TermsUiIntent.ClickContinue -> handleClickContinue() } + } - private fun toggleAll() { - val newChecked = !currentState.isAllChecked + private fun toggleAll() { + val newChecked = !currentState.isAllChecked - update { - copy( - isTermsOfServiceChecked = newChecked, - isPrivacyPolicyChecked = newChecked, - isMarketingConsentChecked = newChecked, - ) - } + updateState { + copy( + isTermsOfServiceChecked = newChecked, + isPrivacyPolicyChecked = newChecked, + isMarketingConsentChecked = newChecked, + ) } + } - private fun toggleTermsOfService() { - update { copy(isTermsOfServiceChecked = !isTermsOfServiceChecked) } - } + private fun toggleTermsOfService() { + updateState { copy(isTermsOfServiceChecked = !isTermsOfServiceChecked) } + } - private fun togglePrivacyPolicy() { - update { copy(isPrivacyPolicyChecked = !isPrivacyPolicyChecked) } - } + private fun togglePrivacyPolicy() { + updateState { copy(isPrivacyPolicyChecked = !isPrivacyPolicyChecked) } + } - private fun toggleMarketingConsent() { - update { copy(isMarketingConsentChecked = !isMarketingConsentChecked) } - } + private fun toggleMarketingConsent() { + updateState { copy(isMarketingConsentChecked = !isMarketingConsentChecked) } + } - private fun handleClickContinue() { - if (!currentState.isRequiredChecked) return - viewModelScope.launch { - _uiEffect.send(TermsUiEffect.NavigateToHome) - } + private fun handleClickContinue() { + if (!currentState.isRequiredChecked) return + viewModelScope.launch { + sendEffect(TermsUiEffect.NavigateToHome) } } +} diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiEffect.kt index 69e4755e..cd5a818c 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiEffect.kt @@ -1,5 +1,7 @@ package com.team.prezel.feature.login.impl.terms.contract -internal sealed interface TermsUiEffect { +import com.team.prezel.core.ui.UiEffect + +internal sealed interface TermsUiEffect : UiEffect { data object NavigateToHome : TermsUiEffect } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiIntent.kt index d4c0264c..5069e2e8 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiIntent.kt @@ -1,6 +1,8 @@ package com.team.prezel.feature.login.impl.terms.contract -internal sealed interface TermsUiIntent { +import com.team.prezel.core.ui.UiIntent + +internal sealed interface TermsUiIntent : UiIntent { data object ToggleAll : TermsUiIntent data object ToggleTermsOfService : TermsUiIntent diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiState.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiState.kt index d9bfad75..581c1ac7 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiState.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/terms/contract/TermsUiState.kt @@ -1,13 +1,14 @@ package com.team.prezel.feature.login.impl.terms.contract import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.UiState @Immutable internal data class TermsUiState( val isTermsOfServiceChecked: Boolean = false, val isPrivacyPolicyChecked: Boolean = false, val isMarketingConsentChecked: Boolean = false, -) { +) : UiState { val isRequiredChecked: Boolean = isTermsOfServiceChecked && isPrivacyPolicyChecked val isAllChecked: Boolean = isTermsOfServiceChecked && isPrivacyPolicyChecked && isMarketingConsentChecked diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt index 8eaeeef0..c949e706 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt @@ -18,8 +18,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY -import com.team.prezel.feature.splash.impl.viewModel.SplashUiEffect -import com.team.prezel.feature.splash.impl.viewModel.SplashViewModel +import com.team.prezel.feature.splash.impl.contract.SplashUiEffect +import com.team.prezel.feature.splash.impl.contract.SplashUiIntent import com.team.prezel.core.designsystem.R as DSR @Composable @@ -31,6 +31,8 @@ internal fun SharedTransitionScope.SplashScreen( viewModel: SplashViewModel = hiltViewModel(), ) { LaunchedEffect(Unit) { + viewModel.onIntent(SplashUiIntent.CheckLoginStatus) + viewModel.uiEffect.collect { effect -> when (effect) { SplashUiEffect.NavigateToHome -> navigateToHome() diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt new file mode 100644 index 00000000..87587b4d --- /dev/null +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -0,0 +1,31 @@ +package com.team.prezel.feature.splash.impl + +import androidx.lifecycle.viewModelScope +import com.team.prezel.core.ui.BaseViewModel +import com.team.prezel.feature.splash.impl.contract.SplashUiEffect +import com.team.prezel.feature.splash.impl.contract.SplashUiIntent +import com.team.prezel.feature.splash.impl.contract.SplashUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class SplashViewModel @Inject constructor() : + BaseViewModel( + SplashUiState(), + ) { + override fun onIntent(intent: SplashUiIntent) { + when (intent) { + SplashUiIntent.CheckLoginStatus -> checkLoginStatus() + } + } + + private fun checkLoginStatus() { + updateState { copy(isLoading = true) } + + viewModelScope + .launch { + sendEffect(SplashUiEffect.NavigateToLogin) + }.invokeOnCompletion { updateState { copy(isLoading = false) } } + } + } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt new file mode 100644 index 00000000..82273d89 --- /dev/null +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt @@ -0,0 +1,9 @@ +package com.team.prezel.feature.splash.impl.contract + +import com.team.prezel.core.ui.UiEffect + +sealed interface SplashUiEffect : UiEffect { + data object NavigateToHome : SplashUiEffect + + data object NavigateToLogin : SplashUiEffect +} diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiIntent.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiIntent.kt new file mode 100644 index 00000000..328baf6c --- /dev/null +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiIntent.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.splash.impl.contract + +import com.team.prezel.core.ui.UiIntent + +sealed interface SplashUiIntent : UiIntent { + data object CheckLoginStatus : SplashUiIntent +} diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiState.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiState.kt new file mode 100644 index 00000000..6a06fb06 --- /dev/null +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiState.kt @@ -0,0 +1,9 @@ +package com.team.prezel.feature.splash.impl.contract + +import androidx.compose.runtime.Immutable +import com.team.prezel.core.ui.UiState + +@Immutable +internal data class SplashUiState( + val isLoading: Boolean = false, +) : UiState diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashUiEffect.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashUiEffect.kt deleted file mode 100644 index bdc64ff2..00000000 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashUiEffect.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.feature.splash.impl.viewModel - -sealed interface SplashUiEffect { - data object NavigateToHome : SplashUiEffect - - data object NavigateToLogin : SplashUiEffect -} diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashUiState.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashUiState.kt deleted file mode 100644 index fc80c6d8..00000000 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashUiState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.team.prezel.feature.splash.impl.viewModel - -import androidx.compose.runtime.Immutable - -@Immutable -sealed interface SplashUiState diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashViewModel.kt deleted file mode 100644 index b63a8725..00000000 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/viewModel/SplashViewModel.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.team.prezel.feature.splash.impl.viewModel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -internal class SplashViewModel - @Inject - constructor() : ViewModel() { - private val _uiEffect = Channel() - val uiEffect: Flow = _uiEffect.receiveAsFlow() - - init { - checkLoginStatus() - } - - private fun checkLoginStatus() { - viewModelScope.launch { - _uiEffect.send(SplashUiEffect.NavigateToLogin) - } - } - } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 33322f26..9bc7cba1 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -42,6 +42,7 @@ includeAuto( "core:auth", ":core:data", ":core:designsystem", + ":core:model", ":core:network", ":core:navigation", ":core:ui",