diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt index 9267faa..1ba1dec 100644 --- a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/Icons.kt @@ -28,6 +28,10 @@ val NotificationIcon: ImageVector @Composable get() = ImageVector.vectorResource(id = R.drawable.ic_notification) +val SearchIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_search) + val TrashIcon: ImageVector @Composable get() = ImageVector.vectorResource(id = R.drawable.ic_trash) @@ -86,4 +90,12 @@ val NavMyCampaignsSelectedIcon: ImageVector val NavMyPageSelectedIcon: ImageVector @Composable - get() = ImageVector.vectorResource(id = R.drawable.ic_nav_my_page) \ No newline at end of file + get() = ImageVector.vectorResource(id = R.drawable.ic_nav_my_page) + +val CherryUnselectedIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_cherry_unselected) + +val CherrySelectedIcon: ImageVector + @Composable + get() = ImageVector.vectorResource(id = R.drawable.ic_cherry_selected) \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanCampaignItem.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanCampaignItem.kt new file mode 100644 index 0000000..2cfdc86 --- /dev/null +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanCampaignItem.kt @@ -0,0 +1,219 @@ +package com.hyunjung.core.presentation.designsystem.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +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.Companion.Unspecified +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hyunjung.core.presentation.designsystem.CherryUnselectedIcon +import com.hyunjung.core.presentation.designsystem.CherrydanColors +import com.hyunjung.core.presentation.designsystem.CherrydanTheme +import com.hyunjung.core.presentation.designsystem.CherrydanTypography +import com.hyunjung.core.presentation.designsystem.R + +fun LazyGridScope.CampaignItem( + item: CampaignItemData, + modifier: Modifier = Modifier, +) { + item(key = item.id) { + CampaignItemContent( + item = item, + modifier = modifier.fillMaxWidth() + ) + } +} + +@Composable +fun CampaignItemContent( + item: CampaignItemData, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier.fillMaxWidth() + ) { + CampaignCard( + label = item.label, + ) + Text( + text = "${item.endDate}일 남음", + style = CherrydanTypography.Main5_R, + color = CherrydanColors.MainPink3, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 4.dp) + ) + + Text( + text = item.title, + style = CherrydanTypography.Main5_R, + color = CherrydanColors.Black, + fontWeight = FontWeight.Bold, + ) + Text( + text = item.reward, + style = CherrydanTypography.Main5_R, + color = CherrydanColors.Black, + ) + + Text( + text = buildAnnotatedString { + append("신청 ${item.totalApplyCount}/") + withStyle(style = SpanStyle(color = CherrydanColors.Gray4)) { + append("${item.currentApplyCount}명") + } + }, + style = CherrydanTypography.Main5_R, + color = CherrydanColors.Black, + ) + + // todo : type에 따라 icon 및 텍스트 enum class 로 생성 해서 교체 필요 + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painter = painterResource(R.drawable.img_google), + contentDescription = null, + tint = Unspecified, + modifier = Modifier.size(16.dp) + ) + Text( + text = item.type, + style = CherrydanTypography.Main5_R, + color = CherrydanColors.Black, + ) + } + } +} + +// todo : image url 받고, coil로 처리 +@Composable +fun CampaignCard( + label: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(4.dp)) + ) { + Image( + painter = painterResource(R.drawable.img_campaign_sample), + contentScale = ContentScale.FillBounds, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + Text( + text = label, + color = CherrydanColors.PointBlue, + style = CherrydanTypography.Main6_R, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 4.dp, bottomEnd = 4.dp)) + .background(color = CherrydanColors.Gray5) + .padding(horizontal = 10.dp, vertical = 8.dp) + .align(Alignment.TopStart) + ) + + // todo : selected / unselected icon 지정 및 크기 조정 하기 + Icon( + imageVector = CherryUnselectedIcon, + modifier = Modifier + .padding(8.dp) + .size(28.dp) + .align(Alignment.BottomEnd) + .clickable {}, + tint = CherrydanColors.White, + contentDescription = null + ) + } +} + +// todo: data 싹 고쳐야함 +data class CampaignItemData( + val id: Int, + val label: String, + val endDate: Int, + val title: String, + val reward: String, + val totalApplyCount: Int, + val currentApplyCount: Int, + val type: String, +) + +@Preview +@Composable +private fun CampaignItemPreview() { + val items = listOf( + CampaignItemData( + id = 1, + label = "강남맛집", + endDate = 6, + title = "[와모야] 맛있는 효소 릴스 체험", + reward = "효소 1박스", + totalApplyCount = 23, + currentApplyCount = 12, + type = "유튜브" + ), + CampaignItemData( + id = 2, + label = "강남맛집", + endDate = 6, + title = "[와모야] 맛있는 효소 릴스 체험", + reward = "효소 1박스", + totalApplyCount = 23, + currentApplyCount = 12, + type = "유튜브" + ), + CampaignItemData( + id = 3, + label = "강남맛집", + endDate = 6, + title = "[와모야] 맛있는 효소 릴스 체험", + reward = "효소 1박스", + totalApplyCount = 23, + currentApplyCount = 12, + type = "유튜브" + ) + ) + CherrydanTheme { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(28.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + + ) { + items.forEach { CampaignItem(it) } + } + } +} \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanScrollableTabRow.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanScrollableTabRow.kt new file mode 100644 index 0000000..eea9d58 --- /dev/null +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanScrollableTabRow.kt @@ -0,0 +1,317 @@ +package com.hyunjung.core.presentation.designsystem.component + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFold +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed +import com.hyunjung.auth.presentation.LocalCherrydanContentColor +import com.hyunjung.core.presentation.designsystem.CherrydanColors +import com.hyunjung.core.presentation.designsystem.CherrydanTheme +import com.hyunjung.core.presentation.designsystem.CherrydanTypography +import com.hyunjung.core.presentation.designsystem.component.CherrydanScrollableTabRowDefaults.tabIndicatorOffset +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun CherrydanScrollableTabRow( + selectedTabIndex: Int, + modifier: Modifier = Modifier, + containerColor: Color = CherrydanScrollableTabRowDefaults.ContainerColor, + contentColor: Color = CherrydanScrollableTabRowDefaults.ContentColor, + edgePadding: Dp = CherrydanScrollableTabRowDefaults.EdgePadding, + tabSpacing: Dp = CherrydanScrollableTabRowDefaults.TabRowSpacing, + indicator: @Composable (tabPositions: List) -> Unit = + @Composable { tabPositions -> + CherrydanScrollableTabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]) + ) + }, + tabs: @Composable () -> Unit +) { + ScrollableTabRowWithSubComposeImpl( + selectedTabIndex = selectedTabIndex, + indicator = indicator, + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + edgePadding = edgePadding, + tabs = tabs, + tabSpacing = tabSpacing, + scrollState = rememberScrollState() + ) +} + +@Composable +private fun ScrollableTabRowWithSubComposeImpl( + selectedTabIndex: Int, + indicator: @Composable (tabPositions: List) -> Unit, + modifier: Modifier = Modifier, + containerColor: Color, + contentColor: Color, + edgePadding: Dp, + tabs: @Composable () -> Unit, + tabSpacing: Dp, + scrollState: ScrollState, +) { + Surface(modifier = modifier, color = containerColor, contentColor = contentColor) { + val coroutineScope = rememberCoroutineScope() + val scrollableTabData = remember(scrollState, coroutineScope) { + ScrollableTabData(scrollState = scrollState, coroutineScope = coroutineScope) + } + SubcomposeLayout( + Modifier + .fillMaxWidth() + .wrapContentSize(align = Alignment.CenterStart) + .horizontalScroll(scrollState) + .selectableGroup() + .clipToBounds() + ) { constraints -> + val padding = edgePadding.roundToPx() + val spacing = tabSpacing.roundToPx() + + val tabMeasurables = subcompose(TabSlots.Tabs, tabs) + + val layoutHeight = + tabMeasurables.fastFold(initial = 0) { curr, measurable -> + maxOf(curr, measurable.maxIntrinsicHeight(Constraints.Infinity)) + } + + val tabConstraints = + constraints.copy( + minHeight = layoutHeight, + maxHeight = layoutHeight, + ) + + val tabPlaceables = mutableListOf() + val tabContentWidths = mutableListOf() + + tabMeasurables.fastForEach { + val placeable = it.measure(tabConstraints) + var contentWidth = + minOf(it.maxIntrinsicWidth(placeable.height), placeable.width).toDp() + contentWidth -= HorizontalTextPadding * 2 + tabPlaceables.add(placeable) + tabContentWidths.add(contentWidth) + } + + val layoutWidth = padding * 2 + + tabPlaceables.sumOf { it.width } + + spacing * (tabPlaceables.size - 1) + + layout(layoutWidth, layoutHeight) { + val tabPositions = mutableListOf() + var left = padding + tabPlaceables.fastForEachIndexed { index, placeable -> + placeable.placeRelative(left, 0) + tabPositions.add( + CherrydanTabPosition( + left = left.toDp(), + width = placeable.width.toDp(), + contentWidth = tabContentWidths[index] + ) + ) + left += placeable.width + spacing + } + + subcompose(TabSlots.Indicator) { indicator(tabPositions) } + .fastForEach { + it.measure(Constraints.fixed(layoutWidth, layoutHeight)).placeRelative(0, 0) + } + + scrollableTabData.onLaidOut( + density = this@SubcomposeLayout, + edgeOffset = padding, + tabPositions = tabPositions, + selectedTab = selectedTabIndex + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CherrydanTabsPreview() { + val tabs = listOf("전체", "지역", "제품", "기자단", "SNS 플랫폼", "체험단 플랫폼") + val selectedIndex = 0 + CherrydanTheme { + CherrydanScrollableTabRow(selectedTabIndex = selectedIndex) { + tabs.forEachIndexed { index, label -> + val selected = index == selectedIndex + CherrydanTab( + selected = selected, + onClick = {} + ) { + Text( + text = label, + color = LocalCherrydanContentColor.current, + style = if (selected) CherrydanTypography.Main4_B else CherrydanTypography.Main4_R + ) + } + } + } + } +} + +object CherrydanScrollableTabRowDefaults { + val TabRowSpacing: Dp = 24.dp + val EdgePadding: Dp = 16.dp + val ContainerColor: Color = Color.Transparent + val ContentColor: Color = CherrydanColors.MainPink3 + + @Composable + fun Indicator( + modifier: Modifier = Modifier, + height: Dp = 2.dp, + color: Color = ContentColor + ) { + Box( + modifier + .fillMaxWidth() + .height(height) + .background(color = color) + ) + } + + fun Modifier.tabIndicatorOffset(currentTabPosition: CherrydanTabPosition): Modifier = + composed( + inspectorInfo = debugInspectorInfo { + name = "tabIndicatorOffset" + value = currentTabPosition + } + ) { + val currentTabWidth by animateDpAsState( + targetValue = currentTabPosition.width, + animationSpec = TabRowIndicatorSpec + ) + val indicatorOffset by animateDpAsState( + targetValue = currentTabPosition.left, + animationSpec = TabRowIndicatorSpec + ) + fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset { IntOffset(indicatorOffset.roundToPx(), 0) } + .width(currentTabWidth) + } +} + +private enum class TabSlots { + Tabs, + Indicator +} + +@Immutable +class CherrydanTabPosition internal constructor(val left: Dp, val width: Dp, val contentWidth: Dp) { + + val right: Dp + get() = left + width + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CherrydanTabPosition) return false + + if (left != other.left) return false + if (width != other.width) return false + if (contentWidth != other.contentWidth) return false + + return true + } + + override fun hashCode(): Int { + var result = left.hashCode() + result = 31 * result + width.hashCode() + result = 31 * result + contentWidth.hashCode() + return result + } + + override fun toString(): String { + return "CherrydanTabPosition(left=$left, right=$right, width=$width, contentWidth=$contentWidth)" + } +} + +private class ScrollableTabData( + private val scrollState: ScrollState, + private val coroutineScope: CoroutineScope +) { + private var selectedTab: Int? = null + + fun onLaidOut( + density: Density, + edgeOffset: Int, + tabPositions: List, + selectedTab: Int + ) { + if (this.selectedTab != selectedTab) { + this.selectedTab = selectedTab + tabPositions.getOrNull(selectedTab)?.let { + val calculatedOffset = it.calculateTabOffset(density, edgeOffset, tabPositions) + if (scrollState.value != calculatedOffset) { + coroutineScope.launch { + scrollState.animateScrollTo( + calculatedOffset, + ScrollableTabRowScrollSpec, + ) + } + } + } + } + } + + private fun CherrydanTabPosition.calculateTabOffset( + density: Density, + edgeOffset: Int, + tabPositions: List + ): Int = + with(density) { + val totalTabRowWidth = tabPositions.last().right.roundToPx() + edgeOffset + val visibleWidth = totalTabRowWidth - scrollState.maxValue + val tabOffset = left.roundToPx() + val scrollerCenter = visibleWidth / 2 + val tabWidth = width.roundToPx() + val centeredTabOffset = tabOffset - (scrollerCenter - tabWidth / 2) + val availableSpace = (totalTabRowWidth - visibleWidth).coerceAtLeast(0) + return centeredTabOffset.coerceIn(0, availableSpace) + } +} + +private val TabRowIndicatorSpec: AnimationSpec = + tween(durationMillis = 250, easing = FastOutSlowInEasing) + +private val ScrollableTabRowScrollSpec: AnimationSpec = + tween(durationMillis = 250, easing = FastOutSlowInEasing) +private val HorizontalTextPadding: Dp = 16.dp \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTab.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTab.kt new file mode 100644 index 0000000..a6e4ef1 --- /dev/null +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTab.kt @@ -0,0 +1,88 @@ +package com.hyunjung.core.presentation.designsystem.component + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.hyunjung.auth.presentation.LocalCherrydanContentColor +import com.hyunjung.core.presentation.designsystem.CherrydanColors + +private const val TabFadeInAnimationDuration = 150 +private const val TabFadeInAnimationDelay = 100 +private const val TabFadeOutAnimationDuration = 100 + +@Composable +fun CherrydanTab( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selectedContentColor: Color = CherrydanColors.MainPink3, + unselectedContentColor: Color = CherrydanColors.Gray4, + paddingValues: PaddingValues = PaddingValues(start = 4.dp, end = 4.dp, bottom = 8.dp), + interactionSource: MutableInteractionSource? = null, + content: @Composable ColumnScope.() -> Unit +) { + TabTransition(selectedContentColor, unselectedContentColor, selected) { + Column( + modifier = + modifier + .selectable( + selected = selected, + onClick = onClick, + enabled = enabled, + role = Role.Tab, + interactionSource = interactionSource, + indication = null + ) + .fillMaxWidth() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + content = content, + ) + } +} + +@Composable +private fun TabTransition( + activeColor: Color, + inactiveColor: Color, + selected: Boolean, + content: @Composable () -> Unit +) { + val transition = updateTransition(selected) + val color by + transition.animateColor( + transitionSpec = { + if (false isTransitioningTo true) { + tween( + durationMillis = TabFadeInAnimationDuration, + delayMillis = TabFadeInAnimationDelay, + easing = LinearEasing + ) + } else { + tween(durationMillis = TabFadeOutAnimationDuration, easing = LinearEasing) + } + } + ) { + if (it) activeColor else inactiveColor + } + CompositionLocalProvider(LocalCherrydanContentColor provides color, content = content) +} \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBar.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBar.kt new file mode 100644 index 0000000..63b7c90 --- /dev/null +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBar.kt @@ -0,0 +1,265 @@ +package com.hyunjung.core.presentation.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.hyunjung.auth.presentation.LocalCherrydanContentColor +import com.hyunjung.core.presentation.designsystem.CherrydanColors +import com.hyunjung.core.presentation.designsystem.CherrydanTypography + +@Composable +fun CherrydanTopAppBar( + title: String, + modifier: Modifier = Modifier, + colors: CherrydanTopAppBarColors = CherrydanTopAppBarDefaults.topAppBarColors(), + navigationIcon: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, + paddingValues: PaddingValues = PaddingValues( + horizontal = CherrydanTopAppBarDefaults.HorizontalPadding, + vertical = CherrydanTopAppBarDefaults.VerticalPadding, + ), + centeredTitle: Boolean = false, +) { + TopAppBarBase( + title = { Text(text = title, style = CherrydanTypography.Title1) }, + actions = actions, + colors = colors, + centeredTitle = centeredTitle, + paddingValues = paddingValues, + navigationIcon = navigationIcon, + modifier = modifier, + ) +} + +@Composable +private fun TopAppBarBase( + title: @Composable (() -> Unit), + colors: CherrydanTopAppBarColors, + navigationIcon: @Composable (() -> Unit)?, + actions: @Composable (RowScope.() -> Unit), + paddingValues: PaddingValues, + centeredTitle: Boolean, + modifier: Modifier, +) { + val actionsRow = @Composable { + Row( + horizontalArrangement = Arrangement.spacedBy( + space = CherrydanTopAppBarDefaults.ActionsSpacing, + ), + verticalAlignment = Alignment.CenterVertically, + content = actions + ) + } + + Box( + modifier = modifier + .fillMaxWidth() + .background(colors.containerColor) + .padding(paddingValues), + ) { + if (navigationIcon != null) { + Box( + modifier = Modifier + .align(Alignment.CenterStart) + ) { + CompositionLocalProvider( + LocalCherrydanContentColor provides colors.navigationIconColor, + content = navigationIcon + ) + } + } + + Box( + modifier = Modifier + .align(if (centeredTitle) Alignment.Center else Alignment.CenterStart) + .padding( + start = if (!centeredTitle && navigationIcon != null) { + CherrydanTopAppBarDefaults.HorizontalPadding + CherrydanTopAppBarDefaults.NavigationButtonSize + } else 0.dp + ) + ) { + CompositionLocalProvider( + LocalDensity provides Density( + density = LocalDensity.current.density, + fontScale = 1f, + ), + LocalCherrydanContentColor provides colors.titleColor, + content = title + ) + } + + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + ) { + CompositionLocalProvider( + LocalCherrydanContentColor provides colors.actionIconColor, + content = actionsRow + ) + } + } +} + +@Composable +fun TopBarIconButton( + imageVector: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + contentDescription: String, +) { + Box( + modifier = modifier + .size(CherrydanTopAppBarDefaults.ActionIconSize) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize() + ) + } +} + +@Composable +fun TopBarIconButton( + painter: Painter, + onClick: () -> Unit, + modifier: Modifier = Modifier, + contentDescription: String, +) { + Box( + modifier = modifier + .size(CherrydanTopAppBarDefaults.ActionIconSize) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + ) + } +} + + +@Preview(showBackground = true) +@Composable +private fun CherrydanTopAppBarNoNavigationPreview() { + Box { + CherrydanTopAppBar( + title = "Title", + actions = { + TopBarIconButton( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + onClick = {} + ) + } + ) + } +} + +@Preview(showBackground = true, name = "TopAppBar With Navigation") +@Composable +private fun CherrydanTopAppBarPreview() { + Box { + CherrydanTopAppBar( + title = "Title", + navigationIcon = { + TopBarIconButton( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + onClick = {} + ) + }, + actions = { + TopBarIconButton( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + onClick = {} + ) + } + ) + } +} + +@Preview(showBackground = true, name = "CenteredTopAppBar") +@Composable +private fun CherrydanTopAppBarCenteredPreview() { + Box { + CherrydanTopAppBar( + title = "Centered Title", + centeredTitle = true, + navigationIcon = { + TopBarIconButton( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + onClick = {} + ) + }, + actions = { + TopBarIconButton( + imageVector = Icons.Default.Favorite, + contentDescription = "Favorite", + onClick = {} + ) + } + ) + } +} + + +object CherrydanTopAppBarDefaults { + + val HorizontalPadding: Dp = 16.dp + val VerticalPadding: Dp = 12.dp + val ActionIconSize: Dp = 40.dp + val ActionButtonSize: Dp = 40.dp + val NavigationButtonSize: Dp = 40.dp + val ActionsSpacing: Dp = 4.dp + + val TitleContentColor: Color = CherrydanColors.Black + val ContainerColor: Color = CherrydanColors.White + val ActionIconColor: Color = CherrydanColors.Black.copy(alpha = 0.8f) + val NavigationIconColor: Color = CherrydanColors.Black.copy(alpha = 0.8f) + + @Composable + fun topAppBarColors( + containerColor: Color = ContainerColor, + titleContentColor: Color = TitleContentColor, + actionIconContentColor: Color = ActionIconColor, + navigationIconContentColor: Color = NavigationIconColor + ): CherrydanTopAppBarColors = CherrydanTopAppBarColors( + containerColor = containerColor, + titleColor = titleContentColor, + actionIconColor = actionIconContentColor, + navigationIconColor = navigationIconContentColor, + ) +} \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBarColors.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBarColors.kt new file mode 100644 index 0000000..031b3a2 --- /dev/null +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/CherrydanTopAppBarColors.kt @@ -0,0 +1,10 @@ +package com.hyunjung.core.presentation.designsystem.component + +import androidx.compose.ui.graphics.Color + +data class CherrydanTopAppBarColors( + val containerColor: Color, + val titleColor: Color, + val navigationIconColor: Color, + val actionIconColor: Color +) diff --git a/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/LocalCherrydanContentColor.kt b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/LocalCherrydanContentColor.kt new file mode 100644 index 0000000..5bad340 --- /dev/null +++ b/core/presentation/designsystem/src/main/java/com/hyunjung/core/presentation/designsystem/component/LocalCherrydanContentColor.kt @@ -0,0 +1,6 @@ +package com.hyunjung.auth.presentation + +import androidx.compose.runtime.compositionLocalOf +import com.hyunjung.core.presentation.designsystem.CherrydanColors + +val LocalCherrydanContentColor = compositionLocalOf { CherrydanColors.Black } \ No newline at end of file diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_cherry_selected.xml b/core/presentation/designsystem/src/main/res/drawable/ic_cherry_selected.xml new file mode 100644 index 0000000..23be423 --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_cherry_selected.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_cherry_unselected.xml b/core/presentation/designsystem/src/main/res/drawable/ic_cherry_unselected.xml new file mode 100644 index 0000000..56ee1cd --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_cherry_unselected.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/core/presentation/designsystem/src/main/res/drawable/ic_search.xml b/core/presentation/designsystem/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..854ccab --- /dev/null +++ b/core/presentation/designsystem/src/main/res/drawable/ic_search.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/presentation/designsystem/src/main/res/drawable/img_campaign_sample.png b/core/presentation/designsystem/src/main/res/drawable/img_campaign_sample.png new file mode 100644 index 0000000..6396fe3 Binary files /dev/null and b/core/presentation/designsystem/src/main/res/drawable/img_campaign_sample.png differ diff --git a/home/presentation/src/main/java/com/hyunjung/home/presentation/home/CategoryTab.kt b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/CategoryTab.kt new file mode 100644 index 0000000..afa989b --- /dev/null +++ b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/CategoryTab.kt @@ -0,0 +1,10 @@ +package com.hyunjung.home.presentation.home + +enum class CategoryTab(val label: String) { + All("전체"), + Region("지역"), + Product("제품"), + Reporter("기자단"), + SNS("SNS 플랫폼"), + Experience("체험단 플랫폼") +} \ No newline at end of file diff --git a/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt index d3f3c2f..1f0002f 100644 --- a/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt +++ b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/HomeScreen.kt @@ -1,11 +1,51 @@ package com.hyunjung.home.presentation.home +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.hyunjung.auth.presentation.LocalCherrydanContentColor import com.hyunjung.core.presentation.designsystem.CherrydanColors +import com.hyunjung.core.presentation.designsystem.CherrydanTheme import com.hyunjung.core.presentation.designsystem.CherrydanTypography +import com.hyunjung.core.presentation.designsystem.NotificationIcon +import com.hyunjung.core.presentation.designsystem.SearchIcon +import com.hyunjung.core.presentation.designsystem.component.CampaignItemContent +import com.hyunjung.core.presentation.designsystem.component.CampaignItemData +import com.hyunjung.core.presentation.designsystem.component.CherrydanScrollableTabRow +import com.hyunjung.core.presentation.designsystem.component.CherrydanScrollableTabRowDefaults +import com.hyunjung.core.presentation.designsystem.component.CherrydanScrollableTabRowDefaults.tabIndicatorOffset +import com.hyunjung.core.presentation.designsystem.component.CherrydanTab +import com.hyunjung.core.presentation.designsystem.component.CherrydanTabPosition +import com.hyunjung.core.presentation.designsystem.component.CherrydanTopAppBar +import com.hyunjung.core.presentation.designsystem.component.TopBarIconButton @Composable fun HomeScreenRoot(modifier: Modifier = Modifier) { @@ -13,16 +53,205 @@ fun HomeScreenRoot(modifier: Modifier = Modifier) { } @Composable -fun HomeScreen(modifier: Modifier = Modifier) { - Text( - text = "홈 화면", - style = CherrydanTypography.Title1, - color = CherrydanColors.Black - ) +fun HomeScreen( + modifier: Modifier = Modifier, + viewModel: String = "FakeViewModel" // todo : 실제 ViewModel을 사용하세요. +) { + HomeContent(modifier = modifier) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HomeContent( + modifier: Modifier = Modifier, +) { + // todo : 실제 데이터는 ViewModel을 통해 가져오는 것이 좋습니다. + val items = (1..29).map { + CampaignItemData( + id = it, + label = "강남맛집", + endDate = 6, + title = "[와모야] 맛있는 효소 릴스 체험 $it", + reward = "효소 1박스", + totalApplyCount = 23, + currentApplyCount = 12, + type = "유튜브" + ) + } + + // todo : viewmodel을 사용하여 상태를 관리하는 것이 좋습니다. + var selectedCategory by remember { mutableStateOf(CategoryTab.All) } + var selectedSort by remember { mutableStateOf(SortTab.Popular) } + + Scaffold( + modifier = modifier, + topBar = { + CherrydanTopAppBar( + // todo : StringResource를 사용하여 문자열을 관리하는 것이 좋습니다. + title = "체리단", + actions = { + TopBarIconButton( + imageVector = NotificationIcon, + contentDescription = "알림", + onClick = {} + ) + TopBarIconButton( + imageVector = SearchIcon, + contentDescription = "검색", + onClick = {} + ) + } + ) + }, + containerColor = CherrydanColors.White + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(104.dp) + .padding(16.dp) + .background( + color = CherrydanColors.MainPink2, + shape = RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "🔥 배너입니다", + style = CherrydanTypography.Title4, + color = CherrydanColors.PointBeige + ) + } + } + stickyHeader { + Column( + modifier = Modifier.background(color = CherrydanColors.White) + ) { + HomeFilter( + tabs = CategoryTab.entries.map { it.label }, + selectedTabIndex = CategoryTab.entries.indexOf(selectedCategory), + selectedContentStyle = CherrydanTypography.Main3_B, + unSelectedContentedStyle = CherrydanTypography.Main3_R, + onTabSelected = { selectedCategory = CategoryTab.entries[it] }, + edgePadding = 20.dp + ) + Spacer(Modifier.height(16.dp)) + HomeFilter( + tabs = SortTab.entries.map { it.label }, + selectedTabIndex = SortTab.entries.indexOf(selectedSort), + onTabSelected = { selectedSort = SortTab.entries[it] }, + indicator = null + ) + } + } + + twoColumnItemsIndexed(items) { index, item -> + CampaignItemContent( + item = item, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} + +@Composable +private fun HomeFilter( + tabs: List, + selectedTabIndex: Int, + selectedContentStyle: TextStyle = CherrydanTypography.Main4_B.copy( + fontSize = 14.sp + ), + unSelectedContentedStyle: TextStyle = CherrydanTypography.Main4_R, + onTabSelected: (Int) -> Unit, + modifier: Modifier = Modifier, + indicator: (@Composable (tabPositions: List) -> Unit)? = + @Composable { tabPositions -> + CherrydanScrollableTabRowDefaults.Indicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]) + ) + }, + edgePadding: Dp = 16.dp +) { + Box(modifier = modifier) { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart), + color = CherrydanColors.PointBeige + ) + CherrydanScrollableTabRow( + selectedTabIndex = selectedTabIndex, + containerColor = Color.Transparent, + contentColor = CherrydanColors.MainPink3, + indicator = indicator ?: {}, + edgePadding = edgePadding, + ) { + tabs.forEachIndexed { index, label -> + val selected = index == selectedTabIndex + CherrydanTab( + selected = selected, + onClick = { if (!selected) onTabSelected(index) }, + ) { + Text( + text = label, + color = LocalCherrydanContentColor.current, + style = if (selected) selectedContentStyle else unSelectedContentedStyle, + ) + } + } + } + } } @Preview @Composable private fun HomeScreenPreview() { - HomeScreen() + CherrydanTheme { + HomeContent() + } +} + +private inline fun LazyListScope.twoColumnItemsIndexed( + items: List, + noinline key: ((index: Int, item: T) -> Any)? = null, + crossinline contentType: (index: Int, item: T) -> Any? = { _, _ -> null }, + crossinline itemContent: @Composable LazyItemScope.(index: Int, item: T) -> Unit +) { + val rowCount = (items.size + 1) / 2 + + items( + count = rowCount, + key = if (key != null) { + { rowIndex -> key(rowIndex * 2, items[rowIndex * 2]) } + } else null, + contentType = { rowIndex -> contentType(rowIndex * 2, items[rowIndex * 2]) } + ) { rowIndex -> + val leftIndex = rowIndex * 2 + val rightIndex = leftIndex + 1 + val leftItem = items[leftIndex] + val rightItem = items.getOrNull(rightIndex) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box(Modifier.weight(1f)) { + itemContent(leftIndex, leftItem) + } + Box(Modifier.weight(1f)) { + if (rightItem != null) { + itemContent(rightIndex, rightItem) + } + } + } + } } \ No newline at end of file diff --git a/home/presentation/src/main/java/com/hyunjung/home/presentation/home/SortTab.kt b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/SortTab.kt new file mode 100644 index 0000000..6272f42 --- /dev/null +++ b/home/presentation/src/main/java/com/hyunjung/home/presentation/home/SortTab.kt @@ -0,0 +1,8 @@ +package com.hyunjung.home.presentation.home + +enum class SortTab(val label: String) { + Popular("인기순"), + Latest("최신순"), + Deadline("마감임박순"), + LowCompetition("경쟁률 낮은순") +} \ No newline at end of file diff --git a/home/presentation/src/main/res/drawable/img_campaign_sample.png b/home/presentation/src/main/res/drawable/img_campaign_sample.png new file mode 100644 index 0000000..6396fe3 Binary files /dev/null and b/home/presentation/src/main/res/drawable/img_campaign_sample.png differ