diff --git a/core/common/src/main/java/store/newsbriefing/app/core/common/util/DateTimeUtil.kt b/core/common/src/main/java/store/newsbriefing/app/core/common/util/DateTimeUtil.kt index 01648f2..3fce7e9 100644 --- a/core/common/src/main/java/store/newsbriefing/app/core/common/util/DateTimeUtil.kt +++ b/core/common/src/main/java/store/newsbriefing/app/core/common/util/DateTimeUtil.kt @@ -1,11 +1,26 @@ package store.newsbriefing.app.core.common.util +import java.text.SimpleDateFormat import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime +import java.util.Date +import java.util.Locale fun String.toZoneDateTime(): ZonedDateTime { val instant = Instant.parse(this) return instant.atZone(ZoneId.systemDefault()) +} + +fun Date.toBriefingDate(): String { + val dateFormat = SimpleDateFormat("yyyy.MM.dd (E)", Locale.KOREAN) + val timeFormat = SimpleDateFormat("HH", Locale.KOREAN) + + val datePart = dateFormat.format(this) + val hourPart = timeFormat.format(this).toInt() + + val briefingPart = if (hourPart < 16) "아침 브리핑" else "저녁 브리핑" + + return "$datePart $briefingPart" } \ No newline at end of file diff --git a/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeCategory.kt b/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeCategory.kt new file mode 100644 index 0000000..ae1be7b --- /dev/null +++ b/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeCategory.kt @@ -0,0 +1,8 @@ +package store.newsbriefing.app.feature.home + +enum class HomeCategory(val title: String) { + SOCIETY("사회"), + SCIENCE("과학"), + GLOBAL("글로벌"), + ECONOMY("경제") +} \ No newline at end of file diff --git a/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeComponents.kt b/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeComponents.kt new file mode 100644 index 0000000..ed8f55c --- /dev/null +++ b/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeComponents.kt @@ -0,0 +1,159 @@ +package store.newsbriefing.app.feature.home + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalMinimumInteractiveComponentEnforcement +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import store.newsbriefing.app.core.designsystem.NoRippleInteractionSource +import store.newsbriefing.app.core.designsystem.theme.BriefingTheme + +@Composable +private fun ScrollableTabIndicator( + indicatorWidth: Dp, + indicatorOffset: Dp, + indicatorColor: Color +) { + Spacer( + modifier = Modifier + .width(indicatorWidth) + .height(1.5.dp) + .offset(indicatorOffset) + .background(color = indicatorColor) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScrollableTabItem( + modifier: Modifier = Modifier, + onClick: () -> Unit, + text: String +) { + CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) { + Surface( + modifier = modifier, + color = Color.Transparent, + onClick = { onClick() }, + interactionSource = NoRippleInteractionSource() + ) { + Text( + modifier = Modifier.padding(bottom = 4.dp), + text = text, + style = BriefingTheme.typography.ContextStyleRegular, + color = BriefingTheme.colorScheme.TextBlack + ) + } + } +} + +@Composable +fun ScrollableTabRow( + modifier: Modifier = Modifier, + items: List, + selectedItemIndex: Int, + indicatorColor: Color = BriefingTheme.colorScheme.PrimaryBlue, + onClick: (index: Int) -> Unit +) { + val density = LocalDensity.current + val sizeList = remember { mutableStateMapOf() } + + val indicatorOffset: Dp by animateDpAsState( + targetValue = sizeList + .values + .take(selectedItemIndex) + .sumOf { it } + 24.dp + 16.dp * selectedItemIndex, + animationSpec = tween(easing = FastOutSlowInEasing) + ) + val indicatorWidth: Dp by animateDpAsState( + targetValue = sizeList[selectedItemIndex] ?: 0.dp, + animationSpec = tween(easing = FastOutSlowInEasing) + ) + + Box(contentAlignment = Alignment.BottomStart) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.5.dp) + .background(color = BriefingTheme.colorScheme.SeperatorGray) + ) + + ScrollableTabIndicator( + indicatorWidth = indicatorWidth, + indicatorOffset = indicatorOffset, + indicatorColor = indicatorColor + ) + + Row( + modifier = modifier + .background(color = Color.Transparent) + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items.forEachIndexed { index, text -> + ScrollableTabItem( + modifier = Modifier.onSizeChanged { + sizeList[index] = with(density) { it.width.toDp() } + }, + onClick = { onClick(index) }, + text = text + ) + } + } + } +} + +private fun Iterable.sumOf(selector: (T) -> Dp): Dp { + var sum: Dp = 0.dp + for (element in this) { + sum += selector(element) + } + return sum +} + +@Preview +@Composable +fun PreviewScrollableTabRow() { + var selectedItemIndex by remember { mutableStateOf(0) } + + BriefingTheme { + ScrollableTabRow( + items = listOf("사회", "과학", "글로벌", "경제"), + selectedItemIndex = selectedItemIndex, + onClick = { selectedItemIndex = it } + ) + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeScreen.kt b/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeScreen.kt index 68d9f41..92ad171 100644 --- a/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/store/newsbriefing/app/feature/home/HomeScreen.kt @@ -1,17 +1,25 @@ package store.newsbriefing.app.feature.home +import android.graphics.Paint.Align import androidx.annotation.DrawableRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon @@ -21,15 +29,31 @@ import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import store.newsbriefing.app.core.common.util.toBriefingDate import store.newsbriefing.app.core.designsystem.theme.BriefingTheme +import store.newsbriefing.app.core.designsystem.theme.ProductSans +import store.newsbriefing.app.core.model.BriefingArticle +import store.newsbriefing.app.core.model.BriefingArticleSummary +import java.time.LocalDateTime +import java.util.Date @Composable internal fun HomeRoute( @@ -53,7 +77,58 @@ internal fun HomeScreenPreview(){ } } -val topics = listOf("사회", "과학","글로벌", "경제") +@Composable +@Preview +internal fun ArticleItemPreview(){ + BriefingTheme { + ArticleItem( + rank = 1, + title = "배터리 혁명", + subtitle = "2차 전지 혁명으로 인한 놀라운 발견과 문제 해결", + scrapCount = 1000, + onClick = {} + ) + } +} + +val categories = HomeCategory.entries +val articles = listOf( + BriefingArticleSummary( + id = 1, + ranks = 1, + title = "배터리 혁명", + subtitle = "2차 전지 혁명으로 인한 놀라운 발견과 문제 해결", + scrapCount = 1000 + ), + BriefingArticleSummary( + id = 2, + ranks = 2, + title = "럼피스킨병 확진", + subtitle = "영남 지역에서 첫 럼피스킨병 확진자가 발생하였다.", + scrapCount = 1000 + ), + BriefingArticleSummary( + id = 3, + ranks = 3, + title = "이스라엘 가자지구", + subtitle = "이스라엘 군이 가자지구 내 군사작전을 계속하면서 우려가 계속되고 있다.", + scrapCount = 1000 + ), + BriefingArticleSummary( + id = 4, + ranks = 4, + title = "국힘 혁신위", + subtitle = "'대사면'건의에 대한 반발이 일어나고 있다.", + scrapCount = 1000 + ), + BriefingArticleSummary( + id = 5, + ranks = 5, + title = "리커창 추모", + subtitle = "리커창에 대한 추모의 열기가 계속되고 있다.", + scrapCount = 1000 + ) +) @OptIn(ExperimentalFoundationApi::class) @Composable @@ -93,9 +168,26 @@ internal fun HomeScreen( } } - val pagerState = rememberPagerState(pageCount = { topics.size }) + val pagerState = rememberPagerState(pageCount = { categories.size }) val scope = rememberCoroutineScope() + var selectedItemIndex by remember { mutableStateOf(0) } + + ScrollableTabRow( + items = categories.map { it.title }, + selectedItemIndex = selectedItemIndex + ) { + selectedItemIndex = it + } + + HorizontalPager(state = pagerState) { page -> + ArticleListSection( + articles = articles, + onClick = { }, + onRefresh = {} + ) + } + } } @@ -116,4 +208,152 @@ private fun ActionBarButton( contentDescription = null ) } +} + +@Composable +private fun ArticleListDate( + createdAt: String, + onRefresh: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp, 8.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = createdAt, + style = BriefingTheme.typography.ContextStyleRegular, + color = BriefingTheme.colorScheme.TextGray + ) + + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .border( + width = 1.dp, + shape = CircleShape, + color = BriefingTheme.colorScheme.TextGray, + + ) + .size(27.dp) + .clip(CircleShape) + .clickable { onRefresh() } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_refresh), + contentDescription = null, + tint = BriefingTheme.colorScheme.TextGray, + modifier = Modifier + .align(Alignment.Center) + ) + } + } +} + +@Composable +private fun ArticleListSection( + articles: List, + onClick: (Int) -> Unit, + onRefresh: () -> Unit +) { + LazyColumn { + item { + ArticleListDate(Date().toBriefingDate()) { + onRefresh() + } + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(BriefingTheme.colorScheme.SeperatorGray) + ) + } + items(articles) { article -> + ArticleItem( + rank = article.ranks, + title = article.title, + subtitle = article.subtitle, + scrapCount = article.scrapCount, + onClick = { onClick(article.id) } + ) + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(BriefingTheme.colorScheme.SeperatorGray) + ) + } + } +} + +@Composable +private fun ArticleItem( + rank: Int, + title: String, + subtitle: String, + scrapCount: Int, + onClick: () -> Unit +) { + Row( + Modifier + .fillMaxWidth() + .clickable { + onClick() + } + .padding( + top = 15.dp, + bottom = 7.dp, + start = 14.dp, + end = 24.dp, + ) + ) { + Text( + modifier = Modifier + .width(54.dp) + .padding(top = 2.dp), + text = "${rank}.", + style = TextStyle( + fontSize = 35.sp, + fontFamily = ProductSans, + fontWeight = FontWeight(700), + color = BriefingTheme.colorScheme.PrimaryBlue, + textAlign = TextAlign.Right, + ) + ) + + Spacer(Modifier.width(16.dp)) + + Column { + Text( + text = title, + style = BriefingTheme.typography.SubtitleStyleBold + ) + Text( + text = subtitle, + style = BriefingTheme.typography.ContextStyleRegular25, + color = BriefingTheme.colorScheme.TextGray, + minLines = 2, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Box(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.align(Alignment.BottomEnd), verticalAlignment = + Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.size(17.dp), + painter = painterResource(id = R.drawable.ic_bookmark), + contentDescription = null, + tint = BriefingTheme.colorScheme.TextGray + ) + Text( + text = "$scrapCount", + style = BriefingTheme.typography.DetailStyleRegular.copy(color = BriefingTheme.colorScheme.TextGray) + ) + } + } + } + } } \ No newline at end of file diff --git a/feature/home/src/main/res/drawable/ic_action_bar_bookmark.xml b/feature/home/src/main/res/drawable/ic_action_bar_bookmark.xml new file mode 100644 index 0000000..e35a48a --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_action_bar_bookmark.xml @@ -0,0 +1,20 @@ + + + + diff --git a/feature/home/src/main/res/drawable/ic_action_bar_setting.xml b/feature/home/src/main/res/drawable/ic_action_bar_setting.xml new file mode 100644 index 0000000..5d79ab2 --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_action_bar_setting.xml @@ -0,0 +1,20 @@ + + + + diff --git a/feature/home/src/main/res/drawable/ic_bookmark.xml b/feature/home/src/main/res/drawable/ic_bookmark.xml new file mode 100644 index 0000000..a8ee876 --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_bookmark.xml @@ -0,0 +1,13 @@ + + + diff --git a/feature/home/src/main/res/drawable/ic_refresh.xml b/feature/home/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..1cfa755 --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,27 @@ + + + + +