diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9d020a9b5..0fdcf186b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,7 +23,7 @@ android { versionName = libs.versions.versionName.get() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - targetSdk = 35 + targetSdk = 36 buildConfigs(rootDir) { string(name = "S3_BASE_URL", key = "s3.url") diff --git a/app/src/main/java/com/into/websoso/core/common/util/navigator/WebsosoNavigator.kt b/app/src/main/java/com/into/websoso/core/common/util/navigator/WebsosoNavigator.kt index bb6448099..fb144627e 100644 --- a/app/src/main/java/com/into/websoso/core/common/util/navigator/WebsosoNavigator.kt +++ b/app/src/main/java/com/into/websoso/core/common/util/navigator/WebsosoNavigator.kt @@ -6,6 +6,7 @@ import com.into.websoso.core.common.navigator.NavigatorProvider import com.into.websoso.ui.login.LoginActivity import com.into.websoso.ui.main.MainActivity import com.into.websoso.ui.normalExplore.NormalExploreActivity +import com.into.websoso.ui.notificationSetting.NotificationSettingActivity import com.into.websoso.ui.novelDetail.NovelDetailActivity import com.into.websoso.ui.onboarding.OnboardingActivity import com.into.websoso.ui.userStorage.UserStorageActivity @@ -66,6 +67,11 @@ internal class WebsosoNavigator val intent = NormalExploreActivity.getIntent(context) startActivity(intent) } + + override fun navigateToNotificationSettingActivity(startActivity: (Intent) -> Unit) { + val intent = NotificationSettingActivity.getIntent(context) + startActivity(intent) + } } @Module diff --git a/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt b/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt index 810b83c70..25649c9e1 100644 --- a/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt @@ -42,6 +42,9 @@ class LibraryFragment : Fragment() { navigateToNovelDetailActivity = { novelId -> websosoNavigator.navigateToNovelDetailActivity(novelId, ::startActivity) }, + navigateToNotificationSettingActivity = { + websosoNavigator.navigateToNotificationSettingActivity(::startActivity) + }, ) } } diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt index f8aa08f59..e23aa3d06 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationEmptyView.kt @@ -37,7 +37,7 @@ fun NovelNotificationEmptyView( modifier = modifier.fillMaxSize(), horizontalAlignment = CenterHorizontally, ) { - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(2f)) Image( imageVector = ImageVector.vectorResource(id = ic_storage_null), contentDescription = null, @@ -64,7 +64,7 @@ fun NovelNotificationEmptyView( color = Primary100, ) } - Spacer(modifier = Modifier.weight(2f)) + Spacer(modifier = Modifier.weight(3f)) } } diff --git a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt index 22008f359..834a74c80 100644 --- a/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt +++ b/app/src/main/java/com/into/websoso/ui/novelNotification/component/NovelNotificationListAppBar.kt @@ -2,19 +2,22 @@ package com.into.websoso.ui.novelNotification.component import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope 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.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Alignment.Companion.CenterEnd +import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale.Companion.FillHeight import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign.Companion.Center +import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.into.websoso.core.common.util.clickableWithoutRipple @@ -30,6 +33,10 @@ import com.into.websoso.domain.model.NovelNotificationType import com.into.websoso.domain.model.NovelNotificationType.COMPLETION import com.into.websoso.domain.model.NovelNotificationType.HIATUS_RETURN import com.into.websoso.ui.novelNotification.novelNotificationTitleRes +import androidx.compose.ui.Alignment.Companion.Center as CenterAlignment + +// 뒤로가기(6 + 44)와 액션 영역을 침범하지 않도록 제목이 쓸 수 있는 좌우 여백 +private val TITLE_HORIZONTAL_MARGIN = 70.dp @Composable fun NovelNotificationListAppBar( @@ -42,30 +49,33 @@ fun NovelNotificationListAppBar( onDeleteButtonClick: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + Box( modifier = modifier .background(White) .fillMaxWidth() - .padding(start = 6.dp, end = 20.dp) .height(44.dp), - verticalAlignment = CenterVertically, ) { Image( painter = painterResource(id = ic_notification_back), contentDescription = null, contentScale = FillHeight, modifier = Modifier + .align(CenterStart) + .padding(start = 6.dp) .size(44.dp) .clickableWithoutRipple { onBackButtonClick() }, ) + // 뒤로가기/액션 유무와 무관하게 제목이 화면 정중앙에 오도록 Box 위에 겹쳐 배치한다 Text( text = stringResource(notificationType.novelNotificationTitleRes()), style = WebsosoTheme.typography.title2, color = Black, textAlign = Center, + maxLines = 1, + overflow = Ellipsis, modifier = Modifier - .weight(1f) - .padding(start = 24.dp), + .align(CenterAlignment) + .padding(horizontal = TITLE_HORIZONTAL_MARGIN), ) if (isActionVisible) { NovelNotificationListAppBarAction( @@ -73,24 +83,28 @@ fun NovelNotificationListAppBar( isDeletable = isDeletable, onEditButtonClick = onEditButtonClick, onDeleteButtonClick = onDeleteButtonClick, + modifier = Modifier + .align(CenterEnd) + .padding(end = 20.dp), ) } } } @Composable -private fun NovelNotificationListAppBarAction( +private fun BoxScope.NovelNotificationListAppBarAction( isEditing: Boolean, isDeletable: Boolean, onEditButtonClick: () -> Unit, onDeleteButtonClick: () -> Unit, + modifier: Modifier = Modifier, ) { when (isEditing) { true -> Text( text = stringResource(novel_notification_delete), style = WebsosoTheme.typography.title2, color = if (isDeletable) Gray300 else Gray200, - modifier = Modifier.clickableWithoutRipple { + modifier = modifier.clickableWithoutRipple { if (isDeletable) onDeleteButtonClick() }, ) @@ -99,7 +113,7 @@ private fun NovelNotificationListAppBarAction( text = stringResource(novel_notification_edit), style = WebsosoTheme.typography.title2, color = Gray300, - modifier = Modifier.clickableWithoutRipple { onEditButtonClick() }, + modifier = modifier.clickableWithoutRipple { onEditButtonClick() }, ) } } diff --git a/app/src/main/res/layout/activity_notification_setting.xml b/app/src/main/res/layout/activity_notification_setting.xml index 7788149b4..be2a22da6 100644 --- a/app/src/main/res/layout/activity_notification_setting.xml +++ b/app/src/main/res/layout/activity_notification_setting.xml @@ -31,7 +31,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="6dp" - android:layout_marginTop="2dp" android:contentDescription="@null" android:padding="10dp" android:src="@drawable/btn_setting_back" @@ -42,8 +41,8 @@ Unit) + + fun navigateToNotificationSettingActivity(startActivity: (Intent) -> Unit) } @EntryPoint diff --git a/core/resource/build.gradle.kts b/core/resource/build.gradle.kts index 8eab46959..f5b21871b 100644 --- a/core/resource/build.gradle.kts +++ b/core/resource/build.gradle.kts @@ -4,7 +4,7 @@ plugins { android { namespace = "com.into.websoso.core.resource" - compileSdk = 35 + compileSdk = 36 defaultConfig { minSdk = 30 diff --git a/core/resource/src/main/res/drawable/ic_library_notification.xml b/core/resource/src/main/res/drawable/ic_library_notification.xml new file mode 100644 index 000000000..8c356fc5e --- /dev/null +++ b/core/resource/src/main/res/drawable/ic_library_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/resource/src/main/res/values/strings.xml b/core/resource/src/main/res/values/strings.xml index 67d9dcd12..c72d9b2c0 100644 --- a/core/resource/src/main/res/values/strings.xml +++ b/core/resource/src/main/res/values/strings.xml @@ -298,8 +298,8 @@ 댓글, 좋아요 등 알림 완결 알림 작품이 완결나면 알림을 드려요 - 휴재 복귀, 외전 알림 - 휴재가 끝나거나 새로운 회차가 생기면 알림을 드려요 + 휴재 복귀 알림 + 새로운 회차가 생기면 알림을 드려요 작품 알림 설정 diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt index fe21e5632..1d45f8c26 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt @@ -1,11 +1,14 @@ package com.into.websoso.feature.library +import android.view.HapticFeedbackConstants import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState @@ -23,6 +26,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.LoadState.Loading @@ -57,6 +61,7 @@ fun LibraryScreen( navigateToNormalExploreActivity: () -> Unit, navigateToNovelDetailActivity: (novelId: Long) -> Unit, libraryViewModel: LibraryViewModel, + navigateToNotificationSettingActivity: (() -> Unit)? = null, ) { val scope = rememberCoroutineScope() val uiState by libraryViewModel.uiState.collectAsStateWithLifecycle() @@ -134,6 +139,7 @@ fun LibraryScreen( onItemClick = { navigateToNovelDetailActivity(it.novelId) }, onSearchClick = navigateToNormalExploreActivity, onExploreClick = navigateToNormalExploreActivity, + onNotificationManageClick = navigateToNotificationSettingActivity, onInterestClick = libraryViewModel::updateInterestedNovels, onAttractivePointClick = libraryViewModel::updateAttractivePoints, onReadStatusClick = libraryViewModel::updateReadStatus, @@ -148,6 +154,19 @@ fun LibraryScreen( ) } +// 당겨서 새로고침은 중첩 스크롤로 동작해 스크롤 가능한 자식이 없으면 제스처가 전달되지 않는다. +// 빈 화면은 스크롤할 내용이 없으므로 화면 높이만큼 차지하는 항목 하나로 감싸 제스처만 살린다. +@Composable +private fun RefreshableContainer(content: @Composable () -> Unit) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + Box(modifier = Modifier.fillParentMaxSize()) { + content() + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun LibraryScreen( @@ -182,7 +201,10 @@ private fun LibraryScreen( onKeywordClick: (String) -> Unit, onResetClick: () -> Unit, onFilterSearchClick: () -> Unit, + onNotificationManageClick: (() -> Unit)?, ) { + val view = LocalView.current + Column( modifier = Modifier .fillMaxSize() @@ -192,8 +214,6 @@ private fun LibraryScreen( LibraryTopBar(onSearchClick = onSearchClick) - Spacer(modifier = Modifier.height(9.dp)) - LibraryFilterTopBar( libraryFilterUiModel = uiState.libraryFilterUiModel, totalCount = uiState.novelTotalCount, @@ -202,19 +222,25 @@ private fun LibraryScreen( onSortClick = onSortClick, onToggleViewType = onToggleViewType, onInterestClick = onInterestClick, + onNotificationManageClick = onNotificationManageClick, ) PullToRefreshBox( isRefreshing = isNovelsRefreshing, - onRefresh = novels::refresh, + onRefresh = { + view.performHapticFeedback(HapticFeedbackConstants.CONFIRM) + novels.refresh() + }, ) { when { novels.itemCount == 0 && novels.loadState.refresh !is Loading -> { - if (uiState.libraryFilterUiModel.isFilterApplied) { - LibraryFilterEmptyView() - } else { - LibraryEmptyView(onExploreClick = onExploreClick) + RefreshableContainer { + if (uiState.libraryFilterUiModel.isFilterApplied) { + LibraryFilterEmptyView() + } else { + LibraryEmptyView(onExploreClick = onExploreClick) + } } } diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridList.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridList.kt index 080a1a089..3c2d55812 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridList.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridList.kt @@ -27,7 +27,7 @@ internal fun LibraryGridList( columns = GridCells.Fixed(3), state = gridState, modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 20.dp), + contentPadding = PaddingValues(start = 20.dp, top = 16.dp, end = 20.dp, bottom = 20.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(18.dp), ) { diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt index 80678b37f..ba845fbdd 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt @@ -67,24 +67,29 @@ internal fun NovelGridListItem( size = itemSize, ) - Text( - text = item.title, - style = WebsosoTheme.typography.body4, - color = Black, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - if (item.ratingStars.isNotEmpty()) { - NovelRatingStar(stars = item.ratingStars) - } - - item.formattedDateRange?.let { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - text = it, - style = WebsosoTheme.typography.label2, - color = Gray200, + text = item.title, + style = WebsosoTheme.typography.body4, + color = Black, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) + + if (item.ratingStars.isNotEmpty()) { + NovelRatingStar( + stars = item.ratingStars, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + + item.formattedDateRange?.let { + Text( + text = it, + style = WebsosoTheme.typography.label2, + color = Gray200, + ) + } } } } @@ -120,9 +125,9 @@ private fun NovelGridThumbnail( imageVector = ImageVector.vectorResource(id = ic_library_interesting), contentDescription = null, modifier = Modifier - .size(30.dp) .align(Alignment.BottomEnd) - .padding(6.dp), + .padding(10.dp) + .size(13.dp), ) } } @@ -181,7 +186,7 @@ internal fun NovelRatingStar( Image( imageVector = ratingStarIcon(starType), contentDescription = null, - modifier = Modifier.size(14.dp), + modifier = Modifier.size(9.dp), ) } } diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryList.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryList.kt index 082133aa1..44f9d348f 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryList.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryList.kt @@ -21,7 +21,7 @@ internal fun LibraryList( LazyColumn( modifier = modifier.fillMaxSize(), state = listState, - contentPadding = PaddingValues(start = 20.dp, bottom = 20.dp), + contentPadding = PaddingValues(start = 20.dp, top = 16.dp, bottom = 20.dp), verticalArrangement = Arrangement.spacedBy(28.dp), ) { items(novels.itemCount) { index -> diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt index a242bca44..336d9b696 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -16,6 +15,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -63,6 +63,8 @@ import com.into.websoso.feature.library.model.ReadStatusUiModel private const val THUMBNAIL_WIDTH_RATIO = 60f / 360f private const val THUMBNAIL_HEIGHT_RATIO = 80f / 360f private const val FEED_CARD_WIDTH_RATIO = 0.8611f +private val CONTENT_SPACING = 16.dp +private val ATTRACTIVE_POINT_ROW_HEIGHT = 23.dp @Composable internal fun LibraryListItem( @@ -74,19 +76,25 @@ internal fun LibraryListItem( modifier = modifier .fillMaxWidth() .debouncedClickable { onClick() }, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { + NovelStatusHeader( + readStatus = item.readStatus, + formattedDateRange = item.formattedDateRange, + ) + Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING), + verticalAlignment = Alignment.CenterVertically, ) { NovelThumbnail( thumbnailUrl = item.novelImage, - readStatus = item.readStatus, isInteresting = item.isInterest, ) NovelInfo( item = item, + modifier = Modifier.weight(1f), ) } @@ -102,7 +110,7 @@ internal fun LibraryListItem( } HorizontalDivider( - modifier = Modifier.padding(top = 16.dp), + modifier = Modifier.padding(top = 10.dp), thickness = 1.dp, color = Gray50, ) @@ -110,43 +118,61 @@ internal fun LibraryListItem( } @Composable -private fun NovelThumbnail( - thumbnailUrl: String, +private fun NovelStatusHeader( readStatus: ReadStatusUiModel?, - isInteresting: Boolean, + formattedDateRange: String?, ) { + if (readStatus == null && formattedDateRange == null) return + val size = calculateThumbnailSize() - Column(modifier = Modifier.width(size.width)) { + Row( + horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING), + verticalAlignment = Alignment.CenterVertically, + ) { ReadStatusBadge( readStatusUiModel = readStatus, width = size.width, ) - Spacer(modifier = Modifier.height(6.dp)) + formattedDateRange?.let { + Text( + text = it, + style = WebsosoTheme.typography.body5, + color = Gray300, + ) + } + } +} - Box( - modifier = Modifier - .size(width = size.width, height = size.height) - .clip(RoundedCornerShape(8.dp)), - ) { - AsyncImage( - model = thumbnailUrl, +@Composable +private fun NovelThumbnail( + thumbnailUrl: String, + isInteresting: Boolean, +) { + val size = calculateThumbnailSize() + + Box( + modifier = Modifier + .size(width = size.width, height = size.height) + .clip(RoundedCornerShape(8.dp)), + ) { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + if (isInteresting) { + Image( + imageVector = ImageVector.vectorResource(id = ic_library_interesting), contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(10.dp) + .size(13.dp), ) - - if (isInteresting) { - Image( - imageVector = ImageVector.vectorResource(id = ic_library_interesting), - contentDescription = null, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(4.dp) - .size(16.dp), - ) - } } } } @@ -156,24 +182,26 @@ private fun ReadStatusBadge( readStatusUiModel: ReadStatusUiModel?, width: Dp, ) { - readStatusUiModel?.let { - Box( - modifier = Modifier - .width(width) - .background( - color = it.backgroundColor, - shape = RoundedCornerShape(8.dp), - ).padding(vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = it.readStatus.label, - color = White, - style = WebsosoTheme.typography.label2, - softWrap = false, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Box(modifier = Modifier.width(width)) { + readStatusUiModel?.let { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = it.backgroundColor, + shape = RoundedCornerShape(8.dp), + ).padding(horizontal = 6.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = it.readStatus.label, + color = White, + style = WebsosoTheme.typography.label2, + softWrap = false, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -188,68 +216,30 @@ private fun calculateThumbnailSize(): ThumbnailUiSize { } @Composable -private fun NovelInfo(item: NovelUiModel) { - Column { - Spacer(modifier = Modifier.height(2.dp)) - NovelInfoDate(item = item) - Spacer(modifier = Modifier.height(4.dp)) - NovelInfoContent( - title = item.title, - myRating = item.userNovelRating, - totalRating = item.novelRating, - attractivePoints = item.attractivePoints, - ) - } -} - -@Composable -private fun NovelInfoDate(item: NovelUiModel) { - Box(modifier = Modifier.height(18.dp)) { - item.formattedDateRange?.let { - Text( - text = it, - style = WebsosoTheme.typography.body5, - color = Gray300, - ) - } - } -} - -@Composable -private fun NovelInfoContent( - title: String, - myRating: NovelRating?, - totalRating: Float, - attractivePoints: AttractivePoints, +private fun NovelInfo( + item: NovelUiModel, + modifier: Modifier = Modifier, ) { - val size = calculateThumbnailSize() - Column( - modifier = Modifier.height(size.height), - verticalArrangement = Arrangement.Center, + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = title, - style = WebsosoTheme.typography.title2, - color = Black, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - NovelRatings( - myRating = myRating, - totalRating = totalRating, - ) - - Spacer(modifier = Modifier.height(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = item.title, + style = WebsosoTheme.typography.title2, + color = Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) - if (attractivePoints.value.isNotEmpty()) { - AttractivePointTags(attractivePoints = attractivePoints) - } else { - Box(modifier = Modifier.height(18.dp)) + NovelRatings( + myRating = item.userNovelRating, + totalRating = item.novelRating, + ) } + + AttractivePointTags(attractivePoints = item.attractivePoints) } } @@ -258,74 +248,102 @@ private fun NovelRatings( myRating: NovelRating?, totalRating: Float, ) { - Row(verticalAlignment = Alignment.CenterVertically) { - myRating?.let { - MyRatingSection(rating = it.rating.value) - Spacer(modifier = Modifier.width(10.dp)) - } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(7.dp), + ) { + myRating?.let { MyRatingSection(rating = it.rating.value) } + TotalRatingSection(rating = totalRating) } } @Composable private fun MyRatingSection(rating: Float) { - Image( - imageVector = ImageVector.vectorResource(id = ic_storage_star), - contentDescription = null, - modifier = Modifier.size(10.dp), - ) - - Spacer(modifier = Modifier.width(2.dp)) - - Text( - text = "$rating", - style = WebsosoTheme.typography.body5Secondary, - color = Secondary100, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + Box( + modifier = Modifier.size(12.dp), + contentAlignment = Alignment.Center, + ) { + Image( + imageVector = ImageVector.vectorResource(id = ic_storage_star), + contentDescription = null, + modifier = Modifier.size(9.dp), + ) + } - Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "$rating", + style = WebsosoTheme.typography.body5Secondary, + color = Secondary100, + ) + } - Text( - text = "내 별점", - style = WebsosoTheme.typography.body5, - color = Gray300, - ) + Text( + text = "내 별점", + style = WebsosoTheme.typography.body5, + color = Gray300, + ) + } } @Composable private fun TotalRatingSection(rating: Float) { - Icon( - imageVector = ImageVector.vectorResource(id = ic_storage_star), - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = Gray200, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = ImageVector.vectorResource(id = ic_storage_star), + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = Gray200, + ) - Spacer(modifier = Modifier.width(2.dp)) + Text( + text = "$rating", + style = WebsosoTheme.typography.body5, + color = Gray200, + ) + } - Text( - text = "$rating 전체 별점", - style = WebsosoTheme.typography.body5, - color = Gray200, - ) + Text( + text = "전체 별점", + style = WebsosoTheme.typography.body5, + color = Gray200, + ) + } } @Composable private fun AttractivePointTags(attractivePoints: AttractivePoints) { - Row(verticalAlignment = Alignment.CenterVertically) { - attractivePoints.selectedAttractivePoints.forEachIndexed { index, attractivePoint -> - AttractivePointItem(attractivePoint) + val selectedAttractivePoints = attractivePoints.selectedAttractivePoints - if (index < attractivePoints.selectedAttractivePoints.lastIndex) { - Spacer(modifier = Modifier.width(6.dp)) + Row( + modifier = Modifier.height(ATTRACTIVE_POINT_ROW_HEIGHT), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + selectedAttractivePoints.forEachIndexed { index, attractivePoint -> + AttractivePointItem(attractivePoint) - Text( - text = "•", - style = WebsosoTheme.typography.body4, - color = Primary100, + if (index < selectedAttractivePoints.lastIndex) { + Box( + modifier = Modifier + .size(2.dp) + .background(color = Primary100, shape = CircleShape), ) - - Spacer(modifier = Modifier.width(6.dp)) } } } @@ -333,18 +351,19 @@ private fun AttractivePointTags(attractivePoints: AttractivePoints) { @Composable private fun AttractivePointItem(attractivePoint: AttractivePoint) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { Image( imageVector = attractivePointIcon(attractivePoint), contentDescription = attractivePoint.label, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(12.dp), ) - Spacer(modifier = Modifier.width(4.dp)) - Text( text = attractivePoint.label, - style = WebsosoTheme.typography.body4, + style = WebsosoTheme.typography.body5, color = Gray300, ) } @@ -382,8 +401,8 @@ private fun NovelKeywordChip(keyword: String) { style = WebsosoTheme.typography.body5, color = Gray200, modifier = Modifier - .background(color = Primary20, shape = RoundedCornerShape(6.dp)) - .padding(horizontal = 8.dp, vertical = 3.dp), + .background(color = Primary20, shape = RoundedCornerShape(20.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), ) } diff --git a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt index a0c34baa2..e5529f05f 100644 --- a/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt +++ b/feature/library/src/main/java/com/into/websoso/feature/library/component/LibrayFilterTopBar.kt @@ -1,16 +1,19 @@ package com.into.websoso.feature.library.component +import androidx.annotation.DrawableRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -18,32 +21,42 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.IconButton +import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton 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.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.into.websoso.core.common.extensions.debouncedClickable +import com.into.websoso.core.common.extensions.debouncedSelectable import com.into.websoso.core.designsystem.theme.Black +import com.into.websoso.core.designsystem.theme.Gray100 +import com.into.websoso.core.designsystem.theme.Gray20 import com.into.websoso.core.designsystem.theme.Gray200 import com.into.websoso.core.designsystem.theme.Gray300 import com.into.websoso.core.designsystem.theme.Gray50 -import com.into.websoso.core.designsystem.theme.Gray70 +import com.into.websoso.core.designsystem.theme.Gray70New +import com.into.websoso.core.designsystem.theme.Gray80 import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.core.designsystem.theme.White import com.into.websoso.core.resource.R.drawable.ic_library_drop_down_fill import com.into.websoso.core.resource.R.drawable.ic_library_grid import com.into.websoso.core.resource.R.drawable.ic_library_list +import com.into.websoso.core.resource.R.drawable.ic_library_notification import com.into.websoso.core.resource.R.drawable.ic_library_sort import com.into.websoso.domain.library.model.SortCriteria import com.into.websoso.feature.library.filter.LibraryFilterTab import com.into.websoso.feature.library.model.LibraryFilterUiModel +private val VIEW_TYPE_TOGGLE_SHAPE = RoundedCornerShape(16.dp) +private val VIEW_TYPE_TOGGLE_BUTTON_SHAPE = RoundedCornerShape(13.dp) + @Composable internal fun LibraryFilterTopBar( libraryFilterUiModel: LibraryFilterUiModel, @@ -54,33 +67,42 @@ internal fun LibraryFilterTopBar( onToggleViewType: () -> Unit, onInterestClick: () -> Unit, modifier: Modifier = Modifier, + onNotificationManageClick: (() -> Unit)? = null, ) { Column( modifier = modifier.fillMaxWidth(), ) { - Column( + Row( modifier = Modifier .fillMaxWidth() - .padding(start = 20.dp), + .padding(start = 20.dp) + .padding(vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, ) { + LibraryViewTypeToggle( + isGrid = isGrid, + onToggleViewType = onToggleViewType, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + VerticalDivider(height = 29.dp) + + Spacer(modifier = Modifier.width(10.dp)) + NovelFilterChipSection( libraryFilterUiModel = libraryFilterUiModel, onFilterClick = onFilterClick, onInterestClick = onInterestClick, ) - - Spacer(modifier = Modifier.height(10.dp)) - - NovelFilterStatusBar( - totalCount = totalCount, - sortCriteria = libraryFilterUiModel.sortCriteria, - isGrid = isGrid, - onSortClick = onSortClick, - onToggleViewType = onToggleViewType, - ) } - Spacer(modifier = Modifier.height(10.dp)) + NovelFilterStatusBar( + totalCount = totalCount, + sortCriteria = libraryFilterUiModel.sortCriteria, + onSortClick = onSortClick, + onNotificationManageClick = onNotificationManageClick, + ) Box( modifier = Modifier @@ -91,6 +113,87 @@ internal fun LibraryFilterTopBar( } } +@Composable +private fun LibraryViewTypeToggle( + isGrid: Boolean, + onToggleViewType: () -> Unit, +) { + Row( + modifier = Modifier + .width(70.dp) + .height(33.dp) + .background(color = Gray20, shape = VIEW_TYPE_TOGGLE_SHAPE) + .border(width = 1.dp, color = Gray70New, shape = VIEW_TYPE_TOGGLE_SHAPE) + .padding(3.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LibraryViewTypeToggleButton( + iconRes = ic_library_grid, + iconSize = 12.dp, + isSelected = isGrid, + onClick = { if (!isGrid) onToggleViewType() }, + ) + + LibraryViewTypeToggleButton( + iconRes = ic_library_list, + iconSize = 13.dp, + isSelected = isGrid.not(), + onClick = { if (isGrid) onToggleViewType() }, + ) + } +} + +@Composable +private fun RowScope.LibraryViewTypeToggleButton( + @DrawableRes iconRes: Int, + iconSize: Dp, + isSelected: Boolean, + onClick: () -> Unit, +) { + val selectedModifier = when (isSelected) { + true -> { + Modifier + .shadow(elevation = 2.dp, shape = VIEW_TYPE_TOGGLE_BUTTON_SHAPE) + .background(color = White, shape = VIEW_TYPE_TOGGLE_BUTTON_SHAPE) + } + + false -> { + Modifier + } + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .then(selectedModifier) + .clip(VIEW_TYPE_TOGGLE_BUTTON_SHAPE) + .debouncedSelectable(selected = isSelected, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(id = iconRes), + contentDescription = null, + tint = if (isSelected) Black else Gray100, + modifier = Modifier.size(iconSize), + ) + } +} + +@Composable +private fun VerticalDivider( + height: Dp, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .width(1.dp) + .height(height) + .background(color = Gray80), + ) +} + @Composable private fun NovelFilterChipSection( libraryFilterUiModel: LibraryFilterUiModel, @@ -101,7 +204,7 @@ private fun NovelFilterChipSection( modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(end = 20.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), ) { NovelFilterChip( text = "관심", @@ -110,14 +213,6 @@ private fun NovelFilterChipSection( showDropdownIcon = false, ) - Box( - modifier = Modifier - .padding(horizontal = 4.dp) - .width(1.dp) - .height(32.dp) - .background(color = Gray70), - ) - NovelFilterChip( text = libraryFilterUiModel.readStatusLabelText, isSelected = libraryFilterUiModel.readStatuses.isSelected, @@ -168,15 +263,15 @@ private fun NovelFilterChip( Surface( color = backgroundColor, - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(18.dp), modifier = Modifier - .defaultMinSize(minHeight = 32.dp) + .defaultMinSize(minHeight = 33.dp) .debouncedClickable(onClick = onClick), - border = if (!isSelected) BorderStroke(1.dp, Gray70) else null, + border = if (!isSelected) BorderStroke(1.dp, Gray80) else null, ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 9.5.dp, vertical = 6.5.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), ) { Text( text = text, @@ -188,8 +283,8 @@ private fun NovelFilterChip( imageVector = ImageVector.vectorResource(id = ic_library_drop_down_fill), contentDescription = null, modifier = Modifier - .padding(start = 4.dp) - .size(12.dp), + .padding(start = 2.dp) + .size(14.dp), ) } } @@ -200,14 +295,14 @@ private fun NovelFilterChip( private fun NovelFilterStatusBar( totalCount: Long, sortCriteria: SortCriteria, - isGrid: Boolean, onSortClick: () -> Unit, - onToggleViewType: () -> Unit, + onNotificationManageClick: (() -> Unit)?, ) { Row( modifier = Modifier .fillMaxWidth() - .padding(end = 8.dp), + .padding(horizontal = 20.dp) + .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -217,57 +312,55 @@ private fun NovelFilterStatusBar( color = Gray200, ) - Row(verticalAlignment = Alignment.CenterVertically) { - SortTypeSelector( - sortCriteria = sortCriteria, - onClick = onSortClick, - ) - - Box( - modifier = Modifier - .height(12.dp) - .width(1.dp) - .background(color = Gray70), - ) - - IconButton(onClick = onToggleViewType) { - Image( - imageVector = ImageVector.vectorResource( - id = if (isGrid) ic_library_grid else ic_library_list, - ), - contentDescription = null, - modifier = Modifier.size(18.dp), + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + onNotificationManageClick?.let { onClick -> + StatusBarAction( + iconRes = ic_library_notification, + iconSize = 12.dp, + text = "알림 관리", + onClick = onClick, ) + + VerticalDivider(height = 8.dp) } + + StatusBarAction( + iconRes = ic_library_sort, + iconSize = 16.dp, + text = sortCriteria.label, + onClick = onSortClick, + ) } } } @Composable -private fun SortTypeSelector( - sortCriteria: SortCriteria, +private fun StatusBarAction( + @DrawableRes iconRes: Int, + iconSize: Dp, + text: String, onClick: () -> Unit, - modifier: Modifier = Modifier, ) { - TextButton( - onClick = onClick, - modifier = modifier, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), + Row( + modifier = Modifier + .debouncedClickable(onClick = onClick) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Image( - imageVector = ImageVector.vectorResource(id = ic_library_sort), - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - - Spacer(modifier = Modifier.width(4.dp)) + Image( + imageVector = ImageVector.vectorResource(id = iconRes), + contentDescription = null, + modifier = Modifier.size(iconSize), + ) - Text( - text = sortCriteria.label, - style = WebsosoTheme.typography.body4, - color = Gray300, - ) - } + Text( + text = text, + style = WebsosoTheme.typography.body3, + color = Gray300, + ) } }