diff --git a/.gitignore b/.gitignore index 985e5737..e5b64310 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ lint/tmp/ @gemini-skills/ .gemini/logs/ .gemini/cache/ +docs/superpowers/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 53cea82f..55c4ad9a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import java.util.Properties plugins { id("buyornot.android.application") alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.hilt) alias(libs.plugins.ksp) alias(libs.plugins.google.services) @@ -29,8 +30,8 @@ android { defaultConfig { applicationId = "com.sseotdabwa.buyornot" - versionCode = 5 - versionName = "0.2.0" + versionCode = 6 + versionName = "0.3.0" buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${localProperties.getProperty("kakao.nativeAppKey", "")}\"") manifestPlaceholders["NATIVE_APP_KEY"] = localProperties.getProperty("kakao.nativeAppKey", "") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf13dc03..85aa59ed 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + if (event == AuthEvent.FORCE_LOGOUT) { @@ -61,7 +56,7 @@ fun BuyOrNotNavHost( NavHost( navController = navController, - startDestination = SPLASH_ROUTE, + startDestination = SplashRoute, modifier = modifier, ) { splashScreen( @@ -70,7 +65,7 @@ fun BuyOrNotNavHost( navController.navigateToHome( navOptions = androidx.navigation.navOptions { - popUpTo(SPLASH_ROUTE) { inclusive = true } + popUpTo { inclusive = true } launchSingleTop = true }, ) @@ -83,7 +78,7 @@ fun BuyOrNotNavHost( navController.navigateToHome( navOptions = androidx.navigation.navOptions { - popUpTo(AUTH_ROUTE) { inclusive = true } + popUpTo { inclusive = true } launchSingleTop = true }, ) @@ -97,10 +92,14 @@ fun BuyOrNotNavHost( onNotificationClick = navController::navigateToNotification, onProfileClick = navController::navigateToMyPage, onUploadClick = navController::navigateToUpload, + onLinkClick = { url -> navController.navigateToWebView("", url) }, + onImageClick = { urls, page -> navController.navigateToImageViewer(urls, page) }, ) notificationGraph( onBackClick = navController::popBackStack, onNotificationClick = navController::navigateToNotificationDetail, + onLinkClick = { url -> navController.navigateToWebView("", url) }, + onImageClick = { urls, page -> navController.navigateToImageViewer(urls, page) }, ) uploadScreen( onNavigateBack = navController::popBackStack, @@ -109,7 +108,7 @@ fun BuyOrNotNavHost( tab = HomeTab.MY_FEED, navOptions = androidx.navigation.navOptions { - popUpTo(UPLOAD_ROUTE) { + popUpTo { inclusive = true } launchSingleTop = true @@ -122,6 +121,9 @@ fun BuyOrNotNavHost( versionName = BuildConfig.VERSION_NAME, onNavigateToLogin = navController::navigateForceToLogin, ) + imageViewerScreen( + onBackClick = navController::popBackStack, + ) webViewScreen( onBackClick = navController::popBackStack, ) diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt index be76d0ac..6cf89b28 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavDestination import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost @@ -21,25 +20,11 @@ import com.sseotdabwa.buyornot.core.network.AuthEventBus import com.sseotdabwa.buyornot.core.ui.permission.rememberNotificationPermission import com.sseotdabwa.buyornot.core.ui.snackbar.LocalSnackbarState import com.sseotdabwa.buyornot.core.ui.snackbar.rememberBuyOrNotSnackbarState -import com.sseotdabwa.buyornot.feature.auth.navigation.AUTH_ROUTE -import com.sseotdabwa.buyornot.feature.auth.navigation.SPLASH_ROUTE -import com.sseotdabwa.buyornot.feature.home.navigation.HOME_ROUTE +import com.sseotdabwa.buyornot.feature.auth.navigation.AuthRoute +import com.sseotdabwa.buyornot.feature.auth.navigation.SplashRoute +import com.sseotdabwa.buyornot.feature.home.navigation.HomeRoute import com.sseotdabwa.buyornot.navigation.BuyOrNotNavHost -/** - * BuyOrNot 앱의 메인 컴포저블 - * - * 네비게이션과 하단 네비게이션 바를 포함한 앱의 전체 구조를 정의합니다. - * 스플래시 및 로그인 화면에서는 하단 바가 표시되지 않습니다. - * - * 전체 화면이 필요하면 → bottomBarPadding() 함수의 리스트에 라우트 추가 - * 일반 화면이면 → 아무 것도 하지 않아도 자동으로 패딩 적용 - * - * @param authEventBus 인증 관련 이벤트 버스 - * @param onBackPressed 홈 화면에서 뒤로가기 시 앱 종료를 위한 콜백 - * @param onFinish 앱 종료 콜백 (강제 업데이트 시 "종료" 버튼) - * @param viewModel 앱 공통 ViewModel - */ @Composable fun BuyOrNotApp( authEventBus: AuthEventBus, @@ -54,12 +39,10 @@ fun BuyOrNotApp( val isFirstRun by viewModel.isFirstRun.collectAsStateWithLifecycle() - // 홈 화면에서 뒤로가기 시 앱 종료 - BackHandler(enabled = currentDestination?.route == HOME_ROUTE) { + BackHandler(enabled = currentDestination?.route?.startsWith(HomeRoute::class.qualifiedName ?: "") == true) { onBackPressed() } - // 앱 진입 시 최초 1회만 알림 권한 자동 요청 val (hasNotificationPermission, requestNotificationPermission) = rememberNotificationPermission() LaunchedEffect(isFirstRun) { @@ -71,6 +54,11 @@ fun BuyOrNotApp( } } + val isFullscreen = + currentDestination?.route.let { route -> + route == SplashRoute::class.qualifiedName || route == AuthRoute::class.qualifiedName + } + CompositionLocalProvider(LocalSnackbarState provides snackbarState) { Scaffold( containerColor = BuyOrNotTheme.colors.gray0, @@ -83,27 +71,17 @@ fun BuyOrNotApp( modifier = Modifier .consumeWindowInsets(innerPadding) - .bottomBarPadding(currentDestination, innerPadding), + .bottomBarPadding(isFullscreen, innerPadding), ) } } } -/** - * 특정 화면(스플래시, 로그인)에서는 시스템 패딩을 제거하는 확장 함수 - * - * NavHost에 적용되어, 전체 화면이 필요한 스플래시/로그인 화면에서는 - * 시스템 바 영역까지 확장되고, 일반 화면에서는 하단 바 패딩을 적용합니다. - * - * @param currentDestination 현재 네비게이션 목적지 - * @param padding Scaffold의 innerPadding (하단 바 높이 포함) - * @return 조건에 따라 패딩이 적용되거나 제거된 Modifier - */ private fun Modifier.bottomBarPadding( - currentDestination: NavDestination?, + isFullscreen: Boolean, padding: PaddingValues, ): Modifier = - if (currentDestination?.route in listOf(SPLASH_ROUTE, AUTH_ROUTE)) { + if (isFullscreen) { this } else { this.padding(padding) diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index 1e7d0140..552ab55a 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -15,6 +15,7 @@ class AndroidFeatureConventionPlugin : Plugin { apply("com.android.library") apply("org.jetbrains.kotlin.android") apply("org.jetbrains.kotlin.plugin.compose") + apply("org.jetbrains.kotlin.plugin.serialization") } extensions.configure { @@ -30,7 +31,6 @@ class AndroidFeatureConventionPlugin : Plugin { add("implementation", libs.findLibrary("androidx.lifecycle.runtime.compose").get()) add("implementation", libs.findLibrary("androidx.navigation.compose").get()) add("implementation", libs.findLibrary("hilt.navigation.compose").get()) - } } } diff --git a/build.gradle.kts b/build.gradle.kts index 7ee34c41..a1fbf71f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) apply false diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt index 02532cd5..824490a9 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt @@ -1,10 +1,12 @@ package com.sseotdabwa.buyornot.core.data.repository import com.sseotdabwa.buyornot.core.network.api.FeedApiService +import com.sseotdabwa.buyornot.core.network.dto.request.FeedImageRequest import com.sseotdabwa.buyornot.core.network.dto.request.FeedRequest import com.sseotdabwa.buyornot.core.network.dto.request.PresignedUrlRequest import com.sseotdabwa.buyornot.core.network.dto.request.VoteRequest import com.sseotdabwa.buyornot.core.network.dto.response.AuthorDto +import com.sseotdabwa.buyornot.core.network.dto.response.FeedImageDto import com.sseotdabwa.buyornot.core.network.dto.response.FeedItemDto import com.sseotdabwa.buyornot.core.network.dto.response.FeedListResponse import com.sseotdabwa.buyornot.core.network.dto.response.VoteResponse @@ -12,6 +14,7 @@ import com.sseotdabwa.buyornot.core.network.dto.response.getOrThrow import com.sseotdabwa.buyornot.domain.model.Author import com.sseotdabwa.buyornot.domain.model.Feed import com.sseotdabwa.buyornot.domain.model.FeedCategory +import com.sseotdabwa.buyornot.domain.model.FeedImage import com.sseotdabwa.buyornot.domain.model.FeedStatus import com.sseotdabwa.buyornot.domain.model.UploadInfo import com.sseotdabwa.buyornot.domain.model.VoteChoice @@ -29,9 +32,10 @@ class FeedRepositoryImpl @Inject constructor( cursor: Long?, size: Int, feedStatus: String?, + category: List?, ): FeedList = feedApiService - .getFeedList(cursor, size, feedStatus) + .getFeedList(cursor, size, feedStatus, category) .getOrThrow() .toDomain() @@ -87,9 +91,9 @@ class FeedRepositoryImpl @Inject constructor( category: FeedCategory, price: Int, content: String, - s3ObjectKey: String, - imageWidth: Int, - imageHeight: Int, + images: List, + title: String?, + link: String?, ): Long = feedApiService .createFeed( @@ -97,9 +101,16 @@ class FeedRepositoryImpl @Inject constructor( category = category.name, price = price, content = content, - s3ObjectKey = s3ObjectKey, - imageWidth = imageWidth, - imageHeight = imageHeight, + images = + images.map { image -> + FeedImageRequest( + s3ObjectKey = image.s3ObjectKey, + imageWidth = image.imageWidth, + imageHeight = image.imageHeight, + ) + }, + title = title, + link = link, ), ).getOrThrow() .feedId @@ -149,6 +160,7 @@ private fun FeedListResponse.toDomain(): FeedList = private fun FeedItemDto.toDomain(): Feed = Feed( feedId = feedId, + title = title ?: "", content = content, price = String.format(java.util.Locale.KOREA, "%,d", price), category = category.toFeedCategory(), @@ -156,14 +168,20 @@ private fun FeedItemDto.toDomain(): Feed = noCount = noCount, totalCount = totalCount, feedStatus = feedStatus.toFeedStatus(), - s3ObjectKey = s3ObjectKey, - viewUrl = viewUrl, - imageWidth = imageWidth, - imageHeight = imageHeight, + images = images.map { it.toDomain() }, author = author.toDomain(), createdAt = createdAt, hasVoted = hasVoted ?: false, myVoteChoice = myVoteChoice?.toVoteChoice(), + productLink = link, + ) + +private fun FeedImageDto.toDomain(): FeedImage = + FeedImage( + s3ObjectKey = s3ObjectKey, + imageUrl = imageUrl, + imageWidth = imageWidth, + imageHeight = imageHeight, ) private fun String.toFeedCategory(): FeedCategory = diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/NotificationRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/NotificationRepositoryImpl.kt index 5882ccef..e127f92c 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/NotificationRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/NotificationRepositoryImpl.kt @@ -30,5 +30,6 @@ class NotificationRepositoryImpl @Inject constructor( resultPercent = resultPercent, resultLabel = resultLabel, viewUrl = viewUrl, + feedTitle = feedTitle.orEmpty(), ) } diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ActionSheet.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ActionSheet.kt new file mode 100644 index 00000000..52fe55f5 --- /dev/null +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ActionSheet.kt @@ -0,0 +1,137 @@ +package com.sseotdabwa.buyornot.core.designsystem.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +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.graphics.Shape +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons +import com.sseotdabwa.buyornot.core.designsystem.icon.IconResource +import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector +import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme + +/** + * BottomSheet 액션 아이템 하나를 나타내는 데이터 클래스. + * + * @param icon 아이템 왼쪽에 표시할 아이콘 + * @param text 아이템에 표시할 텍스트 + * @param onClick 아이템 클릭 시 실행할 동작 + */ +data class ActionItem( + val icon: IconResource, + val text: String, + val onClick: () -> Unit, +) + +/** + * 아이콘과 텍스트로 구성된 액션 목록을 BottomSheet로 표시하는 컴포넌트. + * + * 각 아이템을 탭하면 해당 액션을 실행하고 시트를 닫는다. + * 선택 상태 없이 액션 실행에만 특화되어 있으며, title과 오버플로우 그라데이션은 없다. + * + * @param actions 표시할 액션 아이템 목록 + * @param onDismissRequest 시트를 닫을 때 호출되는 콜백 + * @param sheetShape 시트의 모양 (기본값: 상단 모서리 26.dp 라운드) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionSheet( + actions: List, + onDismissRequest: () -> Unit, + sheetShape: Shape = RoundedCornerShape(26.dp), +) { + BuyOrNotBottomSheet( + onDismissRequest = onDismissRequest, + isHalfExpandedOnly = true, + sheetShape = sheetShape, + ) { hideSheet -> + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(18.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + items( + count = actions.size, + key = { it }, + ) { index -> + val action = actions[index] + ActionItemRow( + item = action, + onClick = { + action.onClick() + hideSheet() + }, + ) + } + } + } +} + +@Composable +private fun ActionItemRow( + item: ActionItem, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(30.dp) + .clickable(onClick = onClick) + .padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Icon( + imageVector = item.icon.asImageVector(), + contentDescription = null, + tint = BuyOrNotTheme.colors.gray950, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = item.text, + style = BuyOrNotTheme.typography.subTitleS3SemiBold, + color = BuyOrNotTheme.colors.gray950, + ) + } +} + +@Preview(showBackground = true, name = "ActionSheet Preview") +@Composable +private fun ActionSheetPreview() { + BuyOrNotTheme { + ActionSheet( + actions = + listOf( + ActionItem( + icon = BuyOrNotIcons.Camera, + text = "카메라로 직접 찍기", + onClick = {}, + ), + ActionItem( + icon = BuyOrNotIcons.Gallery, + text = "앨범에서 사진 선택", + onClick = {}, + ), + ), + onDismissRequest = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/AlertDialog.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/AlertDialog.kt index 3244c666..0fcbb0fb 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/AlertDialog.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/AlertDialog.kt @@ -53,7 +53,7 @@ fun BuyOrNotAlertDialog( Text( text = title, style = BuyOrNotTheme.typography.titleT1Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BottomSheet.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BottomSheet.kt index 2e6b8b4c..b2355de0 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BottomSheet.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BottomSheet.kt @@ -202,7 +202,7 @@ private fun InteractiveBuyOrNotSheetPreview() { Text( text = "제목", style = BuyOrNotTheme.typography.subTitleS1SemiBold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(10.dp)) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Button.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Button.kt index 6e96314e..b2564416 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Button.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Button.kt @@ -46,7 +46,7 @@ object BuyOrNotButtonDefaults { @Composable fun primaryButtonColors() = BuyOrNotButtonColors( - defaultContainer = BuyOrNotTheme.colors.gray900, + defaultContainer = BuyOrNotTheme.colors.gray950, hoverContainer = BuyOrNotTheme.colors.gray800, pressedContainer = BuyOrNotTheme.colors.gray1000, disabledContainer = BuyOrNotTheme.colors.gray200, diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Chip.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Chip.kt index c04a843f..fcfd6e65 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Chip.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/Chip.kt @@ -1,13 +1,16 @@ package com.sseotdabwa.buyornot.core.designsystem.components import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -19,8 +22,12 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons +import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme @OptIn(ExperimentalMaterial3Api::class) @@ -42,9 +49,9 @@ fun BuyOrNotChip( val backgroundColor by animateColorAsState( targetValue = when { - isSelected -> BuyOrNotTheme.colors.gray900 // Selected + isSelected -> BuyOrNotTheme.colors.gray950 // Selected isPressed || isHovered -> BuyOrNotTheme.colors.gray300 // Hover/Pressed - else -> BuyOrNotTheme.colors.gray200 // Unselected + else -> BuyOrNotTheme.colors.gray0 // Unselected }, label = "backgroundColor", ) @@ -53,7 +60,7 @@ fun BuyOrNotChip( targetValue = when { isSelected -> BuyOrNotTheme.colors.gray0 - else -> BuyOrNotTheme.colors.gray700 + else -> BuyOrNotTheme.colors.gray950 }, label = "contentColor", ) @@ -65,13 +72,19 @@ fun BuyOrNotChip( BuyOrNotTheme.typography.bodyB5Medium } + val borderColor by animateColorAsState( + targetValue = if (isSelected) Color.Transparent else BuyOrNotTheme.colors.gray300, + label = "borderColor", + ) + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { Surface( modifier = modifier, onClick = onClick, - shape = RoundedCornerShape(12.dp), + shape = CircleShape, color = backgroundColor, contentColor = contentColor, + border = BorderStroke(width = 1.dp, color = borderColor), interactionSource = interactionSource, ) { Box( @@ -87,6 +100,41 @@ fun BuyOrNotChip( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BuyOrNotIconChip( + imageVector: ImageVector, + contentDescription: String, + onClick: () -> Unit, + tint: Color = BuyOrNotTheme.colors.gray950, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + Surface( + modifier = modifier, + onClick = onClick, + shape = CircleShape, + color = BuyOrNotTheme.colors.gray0, + border = BorderStroke(width = 1.dp, color = BuyOrNotTheme.colors.gray300), + interactionSource = interactionSource, + ) { + Box( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + @Preview(name = "Chip Preview") @Composable fun BuyOrNotChipPreview() { @@ -100,3 +148,15 @@ fun BuyOrNotChipPreview() { ) } } + +@Preview(name = "IconChip Preview") +@Composable +fun BuyOrNotIconChipPreview() { + BuyOrNotTheme { + BuyOrNotIconChip( + imageVector = BuyOrNotIcons.Sort.asImageVector(), + contentDescription = "정렬 칩", + onClick = {}, + ) + } +} diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ConfirmDialog.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ConfirmDialog.kt index ebc6517f..ec93b6d2 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ConfirmDialog.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ConfirmDialog.kt @@ -47,7 +47,7 @@ fun BuyOrNotConfirmDialog( text = title, modifier = Modifier.padding(horizontal = 6.dp), style = BuyOrNotTheme.typography.titleT2Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(26.dp)) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt index cd4663c1..c304b4e4 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt @@ -1,11 +1,14 @@ package com.sseotdabwa.buyornot.core.designsystem.components +import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -15,6 +18,9 @@ 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.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -29,20 +35,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties import coil.compose.AsyncImage import com.sseotdabwa.buyornot.core.designsystem.R import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector +import com.sseotdabwa.buyornot.core.designsystem.shape.TopArrowBubbleShape import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme +import com.sseotdabwa.buyornot.core.designsystem.util.nonRippleClickable enum class ImageAspectRatio( val ratio: Float, @@ -58,153 +66,251 @@ fun FeedCard( nickname: String, category: String, createdAt: String, + title: String, content: String, - productImageUrl: String, - price: String, // 이미지에 있는 가격 정보 추가 - imageAspectRatio: ImageAspectRatio = ImageAspectRatio.SQUARE, // 이미지 비율 (기본값: 1:1) - isVoteEnded: Boolean, // 투표 종료 여부 - userVotedOptionIndex: Int? = null, // 사용자가 투표한 옵션 인덱스 (null: 투표 안함, 0: 사!, 1: 애매..) + productImageUrls: List, + price: String, + imageAspectRatios: List = listOf(ImageAspectRatio.SQUARE), + isVoteEnded: Boolean, + userVotedOptionIndex: Int? = null, buyVoteCount: Int, maybeVoteCount: Int, totalVoteCount: Int, - onVote: (Int) -> Unit, // 투표 옵션 인덱스 (0: 사!, 1: 애매..) - isOwner: Boolean = false, // 본인 글인지 여부 - voterProfileImageUrl: String = "", // 사용자가 투표한 경우의 프로필 이미지 URL - onDeleteClick: () -> Unit = {}, // 삭제 클릭 콜백 추가 - onReportClick: () -> Unit = {}, // 신고 클릭 콜백 추가 - onBlockClick: () -> Unit = {}, // 차단 클릭 콜백 추가 + onVote: (Int) -> Unit, + isOwner: Boolean = false, + voterProfileImageUrl: String = "", + onDeleteClick: () -> Unit = {}, + onReportClick: () -> Unit = {}, + onBlockClick: () -> Unit = {}, showMoreButton: Boolean = true, + productLink: String? = null, + onLinkClick: (url: String) -> Unit = {}, + showProductLinkTooltip: Boolean = false, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { val hasVoted = userVotedOptionIndex != null val buyPercentage = if (totalVoteCount > 0) (buyVoteCount * 100 / totalVoteCount) else 0 val maybePercentage = if (totalVoteCount > 0) (maybeVoteCount * 100 / totalVoteCount) else 0 - var showMenu by remember { mutableStateOf(false) } - var showFullScreen by remember { mutableStateOf(false) } + val pagerState = rememberPagerState(pageCount = { productImageUrls.size }) + var tooltipVisible by remember(showProductLinkTooltip) { mutableStateOf(showProductLinkTooltip) } Column(modifier = modifier) { - val isInPreviewMode = LocalInspectionMode.current - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (isInPreviewMode) { - Box( - modifier = - Modifier - .size(32.dp) - .clip(CircleShape) - .background(BuyOrNotTheme.colors.gray400), - ) - } else { - AsyncImage( - model = profileImageUrl, - contentDescription = null, - modifier = - Modifier - .size(32.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop, + FeedCardHeader( + profileImageUrl = profileImageUrl, + nickname = nickname, + category = category, + createdAt = createdAt, + isOwner = isOwner, + showMoreButton = showMoreButton, + onDeleteClick = onDeleteClick, + onReportClick = onReportClick, + onBlockClick = onBlockClick, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + if (title.isNotEmpty()) { + Text( + text = title, + modifier = Modifier.padding(horizontal = 4.dp), + style = BuyOrNotTheme.typography.subTitleS3SemiBold, + color = BuyOrNotTheme.colors.gray950, ) - } - Spacer(modifier = Modifier.width(10.dp)) - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = nickname, - style = BuyOrNotTheme.typography.bodyB6Medium, - color = BuyOrNotTheme.colors.gray800, - ) - Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = BuyOrNotIcons.ArrowRight.asImageVector(), - contentDescription = "Arrow Right", - tint = BuyOrNotTheme.colors.gray600, - modifier = Modifier.size(10.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = category, - style = BuyOrNotTheme.typography.bodyB6Medium, - color = BuyOrNotTheme.colors.gray800, - ) - } + Spacer(modifier = Modifier.height(4.dp)) + } + + Text( + text = content, + modifier = Modifier.padding(horizontal = 4.dp), + style = BuyOrNotTheme.typography.bodyB4Medium, + color = BuyOrNotTheme.colors.gray800, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + FeedImageCarousel( + productImageUrls = productImageUrls, + pagerState = pagerState, + imageAspectRatios = imageAspectRatios, + price = price, + productLink = productLink, + showTooltip = tooltipVisible, + onTooltipDismiss = { tooltipVisible = false }, + onFullscreenClick = { page -> onImageClick(productImageUrls, page) }, + onLinkClick = onLinkClick, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + FeedVoteSection( + hasVoted = hasVoted, + isVoteEnded = isVoteEnded, + isOwner = isOwner, + userVotedOptionIndex = userVotedOptionIndex, + buyPercentage = buyPercentage, + maybePercentage = maybePercentage, + totalVoteCount = totalVoteCount, + voterProfileImageUrl = voterProfileImageUrl, + onVote = onVote, + modifier = Modifier.padding(horizontal = 20.dp), + ) + } + } +} + +@Composable +private fun FeedCardHeader( + profileImageUrl: String, + nickname: String, + category: String, + createdAt: String, + isOwner: Boolean, + showMoreButton: Boolean, + onDeleteClick: () -> Unit, + onReportClick: () -> Unit, + onBlockClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isInPreviewMode = LocalInspectionMode.current + var showMenu by remember { mutableStateOf(false) } + + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (isInPreviewMode) { + Box( + modifier = + Modifier + .size(32.dp) + .clip(CircleShape) + .background(BuyOrNotTheme.colors.gray400), + ) + } else { + AsyncImage( + model = profileImageUrl, + contentDescription = null, + modifier = + Modifier + .size(32.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } + Spacer(modifier = Modifier.width(10.dp)) + Column { + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = createdAt, - style = BuyOrNotTheme.typography.bodyB7Medium, - color = BuyOrNotTheme.colors.gray600, + text = nickname, + style = BuyOrNotTheme.typography.bodyB6Medium, + color = BuyOrNotTheme.colors.gray800, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = BuyOrNotIcons.ArrowRight.asImageVector(), + contentDescription = "Arrow Right", + tint = BuyOrNotTheme.colors.gray600, + modifier = Modifier.size(10.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = category, + style = BuyOrNotTheme.typography.bodyB6Medium, + color = BuyOrNotTheme.colors.gray800, ) } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = createdAt, + style = BuyOrNotTheme.typography.bodyB7Medium, + color = BuyOrNotTheme.colors.gray600, + ) } - if (showMoreButton) { - Box { - Icon( - imageVector = BuyOrNotIcons.More.asImageVector(), - contentDescription = "More", - modifier = - Modifier - .size(20.dp) - .clickable { showMenu = true }, - tint = BuyOrNotTheme.colors.gray500, + } + if (showMoreButton) { + Box { + Icon( + imageVector = BuyOrNotIcons.More.asImageVector(), + contentDescription = "More", + modifier = + Modifier + .size(20.dp) + .clickable { showMenu = true }, + tint = BuyOrNotTheme.colors.gray500, + ) + val ownerMenuItems = + listOf( + "삭제하기" to { + showMenu = false + onDeleteClick() + }, + ) + val userMenuItems = + listOf( + "신고하기" to { + showMenu = false + onReportClick() + }, + "차단하기" to { + showMenu = false + onBlockClick() + }, + ) + if (showMenu) { + ActionPopup( + items = if (isOwner) ownerMenuItems else userMenuItems, + onDismiss = { showMenu = false }, ) - val ownerMenuItems = - listOf( - "삭제하기" to { - showMenu = false - onDeleteClick() - }, - ) - val userMenuItems = - listOf( - "신고하기" to { - showMenu = false - onReportClick() - }, - "차단하기" to { - showMenu = false - onBlockClick() - }, - ) - if (showMenu) { - ActionPopup( - items = if (isOwner) ownerMenuItems else userMenuItems, - onDismiss = { showMenu = false }, - ) - } } } } + } +} - Spacer(modifier = Modifier.height(14.dp)) - - // 2. 피드 내용 & 이미지 - Column( - modifier = - Modifier - .background( - color = BuyOrNotTheme.colors.gray100, - shape = RoundedCornerShape(16.dp), - ).clip(RoundedCornerShape(16.dp)) - .padding(16.dp), - ) { - Text( - text = content, - modifier = Modifier.padding(horizontal = 4.dp), - style = BuyOrNotTheme.typography.bodyB4Medium, - color = BuyOrNotTheme.colors.gray900, - ) +@Composable +private fun FeedImageCarousel( + productImageUrls: List, + pagerState: PagerState, + imageAspectRatios: List, + price: String, + productLink: String?, + showTooltip: Boolean, + onTooltipDismiss: () -> Unit, + onFullscreenClick: (pageIndex: Int) -> Unit, + onLinkClick: (url: String) -> Unit, + modifier: Modifier = Modifier, +) { + val isInPreviewMode = LocalInspectionMode.current - Spacer(modifier = Modifier.height(10.dp)) + val firstAspectRatio = imageAspectRatios.firstOrNull() ?: ImageAspectRatio.SQUARE - // 상품 이미지 박스 + Box(modifier = modifier) { + HorizontalPager( + state = pagerState, + contentPadding = PaddingValues(horizontal = 20.dp), + pageSpacing = 10.dp, + modifier = Modifier.animateContentSize(), + ) { page -> Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(imageAspectRatio.ratio) - .clip(RoundedCornerShape(16.dp)), + .aspectRatio(firstAspectRatio.ratio) + .clip(RoundedCornerShape(16.dp)) + .border( + width = 1.dp, + color = BuyOrNotTheme.colors.gray300, + shape = RoundedCornerShape(16.dp), + ).clickable { onFullscreenClick(page) }, ) { if (isInPreviewMode) { Box( @@ -215,7 +321,7 @@ fun FeedCard( ) } else { AsyncImage( - model = productImageUrl, + model = productImageUrls[page], contentDescription = "Product Image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, @@ -242,166 +348,150 @@ fun FeedCard( }, ) - // 이미지 확장 버튼 (원본 크기) - FullscreenButton( - modifier = Modifier.align(Alignment.TopEnd).padding(top = 14.dp, end = 14.dp), - onClick = { - showFullScreen = true - }, - ) - - // 가격 태그 (좌측 하단) - Text( - text = stringResource(R.string.feed_card_price_format, price), - modifier = - Modifier - .align(Alignment.BottomStart) - .padding( - start = 14.dp, - bottom = 16.dp, - ), - color = BuyOrNotTheme.colors.gray0, - style = BuyOrNotTheme.typography.titleT1Bold, - ) - } + if (page == 0 && !productLink.isNullOrEmpty()) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 6.dp), + contentAlignment = Alignment.TopEnd, + ) { + LinkButton( + modifier = Modifier.padding(end = 10.dp), + onClick = { onLinkClick(productLink) }, + ) - Spacer(modifier = Modifier.height(12.dp)) + if (showTooltip) { + // 시각적 버튼 높이(30dp) + 간격(6dp) = 36dp + FeedCardToolTip( + modifier = Modifier.padding(top = 36.dp), + onDismiss = onTooltipDismiss, + ) + } + } + } - // 5. 투표 영역 (VoteOption 또는 VoteProgressItem) - // 사용자가 투표했거나 투표가 종료되었으면 결과 표시 - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (hasVoted || isVoteEnded) { - // 투표 완료 또는 종료: VoteProgressItem으로 결과 표시 - VoteProgressItem( - text = stringResource(R.string.feed_card_vote_buy), - percentage = buyPercentage / 100f, - percentageText = "$buyPercentage%", - progressBarColor = BuyOrNotTheme.colors.gray900, - shouldInvertTextColor = true, - leadingContent = - if (userVotedOptionIndex == 0) { - { - AsyncImage( - model = voterProfileImageUrl, - contentDescription = null, - modifier = - Modifier - .height(20.dp) - .width(20.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop, - ) - } - } else { - null - }, - ) - VoteProgressItem( - text = stringResource(R.string.feed_card_vote_maybe), - percentage = maybePercentage / 100f, - percentageText = "$maybePercentage%", - textColor = BuyOrNotTheme.colors.gray700, - percentageTextColor = BuyOrNotTheme.colors.gray700, - leadingContent = - if (userVotedOptionIndex == 1) { - { - AsyncImage( - model = voterProfileImageUrl, - contentDescription = null, - modifier = - Modifier - .height(20.dp) - .width(20.dp) - .clip(CircleShape), - contentScale = ContentScale.Crop, - ) - } - } else { - null - }, - ) - } else { - // 투표 진행중: VoteOption으로 투표 가능 - VoteOption( - text = stringResource(R.string.feed_card_vote_buy), - onClick = { onVote(0) }, - ) - VoteOption( - text = stringResource(R.string.feed_card_vote_maybe), - onClick = { onVote(1) }, + if (page == 0) { + Text( + text = stringResource(R.string.feed_card_price_format, price), + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(start = 14.dp, bottom = 16.dp), + color = BuyOrNotTheme.colors.gray0, + style = + BuyOrNotTheme.typography.headingH4Bold.copy( + shadow = + Shadow( + color = Color.Black.copy(alpha = 0.3f), + offset = Offset(0f, 4f), + blurRadius = 4f, + ), + ), ) } } - - Spacer(modifier = Modifier.height(10.dp)) - - // 6. 하단 상태 정보 - Row(verticalAlignment = Alignment.CenterVertically) { - val statusText = - if (isVoteEnded) { - stringResource(R.string.feed_card_vote_status_ended) - } else { - stringResource(R.string.feed_card_vote_status_ongoing) - } - Text( - text = stringResource(R.string.feed_card_vote_count_format, totalVoteCount, statusText), - modifier = Modifier.padding(start = 6.dp), - style = BuyOrNotTheme.typography.bodyB7Medium, - color = BuyOrNotTheme.colors.gray600, - ) - } - } - } - - if (showFullScreen) { - Popup( - onDismissRequest = { showFullScreen = false }, - properties = PopupProperties(focusable = true, excludeFromSystemGesture = false), - ) { - FullScreenImageOverlay( - imageUrl = productImageUrl, - onDismiss = { showFullScreen = false }, - ) } } } -/** - * 피드 이미지 확대 오버레이 컴포넌트 - */ @Composable -private fun FullScreenImageOverlay( - imageUrl: String, - onDismiss: () -> Unit, +private fun FeedVoteSection( + hasVoted: Boolean, + isVoteEnded: Boolean, + isOwner: Boolean, + userVotedOptionIndex: Int?, + buyPercentage: Int, + maybePercentage: Int, + totalVoteCount: Int, + voterProfileImageUrl: String, + onVote: (Int) -> Unit, + modifier: Modifier = Modifier, ) { - Box( - modifier = - Modifier - .fillMaxSize() - .background(Color.Black) - .clickable { onDismiss() }, - ) { - AsyncImage( - model = imageUrl, - contentDescription = "Expanded Image", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Fit, - ) + val isTie = buyPercentage == maybePercentage + val hasVotes = totalVoteCount > 0 - Box( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(start = 10.dp, top = 10.dp) - .size(40.dp) - .clickable { onDismiss() }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = BuyOrNotIcons.Close.asImageVector(), - contentDescription = "Close", - tint = Color.White, - modifier = Modifier.size(20.dp), + Column(modifier = modifier) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (hasVoted || isVoteEnded || isOwner) { + VoteProgressItem( + text = stringResource(R.string.feed_card_vote_buy), + percentage = buyPercentage / 100f, + percentageText = "$buyPercentage%", + progressBarColor = BuyOrNotTheme.colors.gray950, + shouldInvertTextColor = true, + textColor = if (isTie && !hasVotes) BuyOrNotTheme.colors.gray700 else BuyOrNotTheme.colors.gray800, + percentageTextColor = if (isTie && !hasVotes) BuyOrNotTheme.colors.gray700 else BuyOrNotTheme.colors.gray950, + leadingContent = + if (userVotedOptionIndex == 0) { + { + AsyncImage( + model = voterProfileImageUrl, + contentDescription = null, + modifier = + Modifier + .height(20.dp) + .width(20.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } + } else { + null + }, + ) + VoteProgressItem( + text = stringResource(R.string.feed_card_vote_maybe), + percentage = maybePercentage / 100f, + percentageText = "$maybePercentage%", + progressBarColor = if (isTie && hasVotes) BuyOrNotTheme.colors.gray950 else BuyOrNotTheme.colors.gray400, + textColor = BuyOrNotTheme.colors.gray700, + percentageTextColor = if (isTie && hasVotes) BuyOrNotTheme.colors.gray950 else BuyOrNotTheme.colors.gray700, + shouldInvertTextColor = isTie && hasVotes, + leadingContent = + if (userVotedOptionIndex == 1) { + { + AsyncImage( + model = voterProfileImageUrl, + contentDescription = null, + modifier = + Modifier + .height(20.dp) + .width(20.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } + } else { + null + }, + ) + } else { + VoteOption( + text = stringResource(R.string.feed_card_vote_buy), + onClick = { onVote(0) }, + ) + VoteOption( + text = stringResource(R.string.feed_card_vote_maybe), + onClick = { onVote(1) }, + ) + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + val statusText = + if (isVoteEnded) { + stringResource(R.string.feed_card_vote_status_ended) + } else { + stringResource(R.string.feed_card_vote_status_ongoing) + } + Text( + text = stringResource(R.string.feed_card_vote_count_format, totalVoteCount, statusText), + modifier = Modifier.padding(start = 6.dp), + style = BuyOrNotTheme.typography.bodyB7Medium, + color = BuyOrNotTheme.colors.gray600, ) } } @@ -432,36 +522,95 @@ private fun VoteOption( vertical = 14.dp, ), style = BuyOrNotTheme.typography.subTitleS4SemiBold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) } } @Composable -private fun FullscreenButton( +private fun LinkButton( modifier: Modifier = Modifier, onClick: () -> Unit, ) { Box( + modifier = modifier.size(40.dp), + contentAlignment = Alignment.TopCenter, + ) { + Box( + modifier = + Modifier + .background( + color = BuyOrNotTheme.colors.gray1000.copy(alpha = 0.4f), + shape = RoundedCornerShape(26.dp), + ).clip(RoundedCornerShape(26.dp)) + .padding( + horizontal = 10.dp, + vertical = 6.dp, + ).clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = BuyOrNotIcons.Link.asImageVector(), + contentDescription = "Link", + modifier = Modifier.size(18.dp), + tint = BuyOrNotTheme.colors.gray0, + ) + } + } +} + +@Composable +fun FeedCardToolTip( + modifier: Modifier = Modifier, + onDismiss: () -> Unit = {}, +) { + val tooltipShape = + remember { + TopArrowBubbleShape( + cornerRadius = 10.dp, + arrowWidth = 10.dp, + arrowHeight = 5.dp, + arrowOffsetFromRight = 30.dp, + ) + } + + Row( modifier = modifier - .size(30.dp) .background( - color = BuyOrNotTheme.colors.gray1000.copy(alpha = 0.5f), - shape = RoundedCornerShape(8.dp), - ).clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, + color = Color(0xCC3A3C3E), + shape = tooltipShape, + ).nonRippleClickable(onClick = onDismiss) + .padding(top = 13.dp, bottom = 8.dp) + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = BuyOrNotIcons.Expand.asImageVector(), - contentDescription = "Fullscreen", - modifier = Modifier.size(18.dp), - tint = BuyOrNotTheme.colors.gray300, + Text( + text = "상품 링크를 확인해보세요!", + style = BuyOrNotTheme.typography.bodyB5Medium, + color = BuyOrNotTheme.colors.gray0, ) } } +@Preview( + name = "FeedCardToolTip", + showBackground = true, + backgroundColor = 0xFFFFFFFF, +) +@Composable +private fun FeedCardToolTipPreview() { + BuyOrNotTheme { + Box( + modifier = Modifier.padding(24.dp), + contentAlignment = Alignment.Center, + ) { + FeedCardToolTip() + } + } +} + @Preview( name = "FeedCard - Square (1:1) Interactive", showBackground = true, @@ -477,10 +626,16 @@ private fun FeedCardSquareInteractivePreview() { nickname = "결정장애", category = "뷰티", createdAt = "10분 전", + title = "립스틱 살까요?", content = "이 립스틱 색상 어때요? 평소에 안 바르던 색인데 도전해볼까 고민중이에요!", - productImageUrl = "https://picsum.photos/seed/product1/800/800", + productImageUrls = + listOf( + "https://picsum.photos/seed/product1/800/800", + "https://picsum.photos/seed/product2/800/800", + "https://picsum.photos/seed/product3/800/800", + ), price = "35,000", - imageAspectRatio = ImageAspectRatio.SQUARE, + imageAspectRatios = listOf(ImageAspectRatio.SQUARE), isVoteEnded = false, userVotedOptionIndex = userVotedOption, buyVoteCount = 20, @@ -491,6 +646,8 @@ private fun FeedCardSquareInteractivePreview() { }, onDeleteClick = {}, onReportClick = {}, + productLink = "link", + showProductLinkTooltip = true, ) } } @@ -510,10 +667,14 @@ private fun FeedCardPortraitInteractivePreview() { nickname = "패션피플", category = "의류", createdAt = "2시간 전", + title = "이 원피스 어때요?", content = "이 원피스 4:5 비율로 보면 더 예쁜 것 같아요! 세로로 긴 옷 사진은 이 비율이 딱이에요.", - productImageUrl = "https://picsum.photos/seed/product2/800/1000", + productImageUrls = + listOf( + "https://picsum.photos/seed/product2/800/1000", + ), price = "89,000", - imageAspectRatio = ImageAspectRatio.PORTRAIT, + imageAspectRatios = listOf(ImageAspectRatio.PORTRAIT), isVoteEnded = false, userVotedOptionIndex = userVotedOption, buyVoteCount = 45, diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/OptionSheet.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/OptionSheet.kt index 1a88359a..3be0d8de 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/OptionSheet.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/OptionSheet.kt @@ -13,11 +13,13 @@ 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.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,6 +59,10 @@ fun OptionSheet( isHalfExpandedOnly = true, sheetShape = sheetShape, ) { hideSheet -> + val listState = rememberLazyListState() + val isOverflowing by remember { + derivedStateOf { listState.canScrollForward || listState.canScrollBackward } + } Box( modifier = Modifier.clip(sheetShape), ) { @@ -70,7 +76,7 @@ fun OptionSheet( Text( text = title, style = BuyOrNotTheme.typography.subTitleS1SemiBold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, modifier = Modifier .padding(horizontal = 24.dp) @@ -81,8 +87,9 @@ fun OptionSheet( // 옵션 목록 영역 LazyColumn( + state = listState, verticalArrangement = Arrangement.spacedBy(18.dp), - contentPadding = PaddingValues(bottom = 48.dp), + contentPadding = PaddingValues(bottom = if (isOverflowing) 48.dp else 12.dp), ) { items( count = options.size, @@ -103,23 +110,25 @@ fun OptionSheet( } } - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(70.dp) - .background( - brush = - Brush.verticalGradient( - colors = - listOf( - Color.Transparent, // 시작점 (위): 투명 - BuyOrNotTheme.colors.gray0, // 끝점 (아래): 배경색 - ), - ), - ), - ) + if (isOverflowing) { + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(70.dp) + .background( + brush = + Brush.verticalGradient( + colors = + listOf( + Color.Transparent, // 시작점 (위): 투명 + BuyOrNotTheme.colors.gray0, // 끝점 (아래): 배경색 + ), + ), + ), + ) + } } } } @@ -150,7 +159,7 @@ private fun OptionItem( }, color = if (isSelected) { - BuyOrNotTheme.colors.gray900 + BuyOrNotTheme.colors.gray950 } else { BuyOrNotTheme.colors.gray700 }, @@ -160,7 +169,7 @@ private fun OptionItem( Icon( imageVector = BuyOrNotIcons.Check.asImageVector(), contentDescription = "Check", - tint = BuyOrNotTheme.colors.gray900, + tint = BuyOrNotTheme.colors.gray950, ) } } diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt index 48097b5f..b29d46e4 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt @@ -109,7 +109,7 @@ fun BackTopBarWithTitle( Text( text = title, style = BuyOrNotTheme.typography.titleT1Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) }, ) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/VoteProgressItem.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/VoteProgressItem.kt index 30d86d38..80d11435 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/VoteProgressItem.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/VoteProgressItem.kt @@ -51,7 +51,7 @@ fun VoteProgressItem( modifier: Modifier = Modifier, progressBarColor: Color = BuyOrNotTheme.colors.gray400, textColor: Color = BuyOrNotTheme.colors.gray800, - percentageTextColor: Color = BuyOrNotTheme.colors.gray900, + percentageTextColor: Color = BuyOrNotTheme.colors.gray950, invertedTextColor: Color = BuyOrNotTheme.colors.gray0, shouldInvertTextColor: Boolean = false, leadingContent: @Composable (() -> Unit)? = null, @@ -247,7 +247,7 @@ private fun VoteProgressItemSelectedPreview() { percentage = 0.9f, percentageText = "90%", modifier = Modifier.padding(16.dp), - progressBarColor = BuyOrNotTheme.colors.gray900, + progressBarColor = BuyOrNotTheme.colors.gray950, shouldInvertTextColor = true, ) } @@ -277,7 +277,7 @@ private fun VoteProgressItemLowPercentagePreview() { percentage = 0.1f, percentageText = "10%", modifier = Modifier.padding(16.dp), - progressBarColor = BuyOrNotTheme.colors.gray900, + progressBarColor = BuyOrNotTheme.colors.gray950, shouldInvertTextColor = true, ) } @@ -292,7 +292,7 @@ private fun VoteProgressItemNoInvertPreview() { percentage = 1f, percentageText = "100%", modifier = Modifier.padding(16.dp), - progressBarColor = BuyOrNotTheme.colors.gray900, + progressBarColor = BuyOrNotTheme.colors.gray950, // ProfileImage 예시 (실제로는 ProfileImage Composable 사용) leadingContent = { Box( @@ -323,7 +323,7 @@ private fun VoteScreenPreview() { text = "사! 가즈아!", percentage = 0.9f, percentageText = "90%", - progressBarColor = BuyOrNotTheme.colors.gray900, + progressBarColor = BuyOrNotTheme.colors.gray950, shouldInvertTextColor = true, leadingContent = { // ProfileImage 예시 (실제로는 ProfileImage Composable 사용) @@ -378,3 +378,55 @@ private fun VoteScreenPreview() { } } } + +@Preview(name = "VoteProgressItem - 동률 0% (투표 없음)", showBackground = true) +@Composable +private fun VoteProgressItemTieZeroPreview() { + BuyOrNotTheme { + Column(modifier = Modifier.padding(16.dp)) { + VoteProgressItem( + text = "사! 가즈아!", + percentage = 0f, + percentageText = "0%", + progressBarColor = BuyOrNotTheme.colors.gray950, + shouldInvertTextColor = true, + textColor = BuyOrNotTheme.colors.gray700, + percentageTextColor = BuyOrNotTheme.colors.gray700, + ) + Spacer(modifier = Modifier.height(8.dp)) + VoteProgressItem( + text = "애매하긴 해..", + percentage = 0f, + percentageText = "0%", + textColor = BuyOrNotTheme.colors.gray700, + percentageTextColor = BuyOrNotTheme.colors.gray700, + ) + } + } +} + +@Preview(name = "VoteProgressItem - 동률 50% (투표 있음)", showBackground = true) +@Composable +private fun VoteProgressItemTieFiftyPreview() { + BuyOrNotTheme { + Column(modifier = Modifier.padding(16.dp)) { + VoteProgressItem( + text = "사! 가즈아!", + percentage = 0.5f, + percentageText = "50%", + progressBarColor = BuyOrNotTheme.colors.gray950, + shouldInvertTextColor = true, + ) + Spacer(modifier = Modifier.height(8.dp)) + VoteProgressItem( + text = "애매하긴 해..", + percentage = 0.5f, + percentageText = "50%", + progressBarColor = BuyOrNotTheme.colors.gray950, + textColor = BuyOrNotTheme.colors.gray700, + percentageTextColor = BuyOrNotTheme.colors.gray950, + shouldInvertTextColor = true, + ) + } + } +} diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotIcons.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotIcons.kt index e0347f39..ee2f2176 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotIcons.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotIcons.kt @@ -32,6 +32,7 @@ object BuyOrNotIcons { val Expand = IconResource(R.drawable.ic_expand) val Check = IconResource(R.drawable.ic_check) val Clock = IconResource(R.drawable.ic_clock) + val Sort = IconResource(R.drawable.ic_sort) // 네비게이션 아이콘 val ArrowLeft = IconResource(R.drawable.ic_arrow_left) @@ -41,6 +42,8 @@ object BuyOrNotIcons { // 기능 아이콘 val CheckCircle = IconResource(R.drawable.ic_check_circle) val Camera = IconResource(R.drawable.ic_camera) + val Gallery = IconResource(R.drawable.ic_gallery) + val Link = IconResource(R.drawable.ic_link) val Vote = IconResource(R.drawable.ic_vote) val VoteDone = IconResource(R.drawable.ic_vote_done) val Bag = IconResource(R.drawable.ic_bag) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotImgs.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotImgs.kt index 25d3bc63..1077b9ed 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotImgs.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/icon/BuyOrNotImgs.kt @@ -35,6 +35,8 @@ object BuyOrNotImgs { val NoNotification = ImgsResource(R.drawable.img_no_bell) val NoBlockedUser = ImgsResource(R.drawable.img_blocked_user_empty) + + val MyFeedEmpty = ImgsResource(R.drawable.img_my_feed_empty) } /** diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/shape/TopArrowBubbleShape.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/shape/TopArrowBubbleShape.kt new file mode 100644 index 00000000..69689dc3 --- /dev/null +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/shape/TopArrowBubbleShape.kt @@ -0,0 +1,52 @@ +package com.sseotdabwa.buyornot.core.designsystem.shape + +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection + +class TopArrowBubbleShape( + private val cornerRadius: Dp, + private val arrowWidth: Dp, + private val arrowHeight: Dp, + private val arrowOffsetFromRight: Dp, +) : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density, + ): Outline = + Outline.Generic( + Path().apply { + val cornerRadiusPx = with(density) { cornerRadius.toPx() } + val arrowWidthPx = with(density) { arrowWidth.toPx() } + val arrowHeightPx = with(density) { arrowHeight.toPx() } + val arrowOffsetPx = with(density) { arrowOffsetFromRight.toPx() } + + // 말풍선 본체 (상단 화살표 영역 제외) + addRoundRect( + RoundRect( + rect = Rect(0f, arrowHeightPx, size.width, size.height), + cornerRadius = CornerRadius(cornerRadiusPx), + ), + ) + + // 상단 우측에 위를 향하는 삼각형 꼬리 + val arrowCenterX = + (size.width - arrowOffsetPx).coerceIn( + minimumValue = cornerRadiusPx + arrowWidthPx / 2, + maximumValue = size.width - cornerRadiusPx - arrowWidthPx / 2, + ) + moveTo(arrowCenterX - arrowWidthPx / 2, arrowHeightPx) + lineTo(arrowCenterX, 0f) + lineTo(arrowCenterX + arrowWidthPx / 2, arrowHeightPx) + close() + }, + ) +} diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/Color.kt index b9e01c14..57a48353 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/Color.kt @@ -9,7 +9,8 @@ internal val LightColorScheme = // Black & Gray black = Color(0xFF000000), gray1000 = Color(0xFF1A1C20), - gray900 = Color(0xFF2A3038), + gray950 = Color(0xFF2A3038), + gray900 = Color(0xFF3F4346), gray800 = Color(0xFF565D6D), gray700 = Color(0xFF868B94), gray600 = Color(0xFFB1B3BB), @@ -32,6 +33,7 @@ data class BuyOrNotColorScheme( // Black & Gray val black: Color, val gray1000: Color, + val gray950: Color, val gray900: Color, val gray800: Color, val gray700: Color, @@ -56,6 +58,7 @@ val LocalColorScheme = // Black & Gray black = Color.Unspecified, gray1000 = Color.Unspecified, + gray950 = Color.Unspecified, gray900 = Color.Unspecified, gray800 = Color.Unspecified, gray700 = Color.Unspecified, diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/ThemePreview.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/ThemePreview.kt index 91189c83..f7aa13aa 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/ThemePreview.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/theme/ThemePreview.kt @@ -49,13 +49,14 @@ private fun ColorCatalogPreview() { Text( text = "Black & Gray", style = BuyOrNotTheme.typography.headingH2Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(16.dp)) ColorItem("black", BuyOrNotTheme.colors.black, "#000000") ColorItem("gray1000", BuyOrNotTheme.colors.gray1000, "#1A1C20") - ColorItem("gray900", BuyOrNotTheme.colors.gray900, "#2A3038") + ColorItem("gray950", BuyOrNotTheme.colors.gray950, "#2A3038") + ColorItem("gray900", BuyOrNotTheme.colors.gray900, "#3F4346") ColorItem("gray800", BuyOrNotTheme.colors.gray800, "#565D6D") ColorItem("gray700", BuyOrNotTheme.colors.gray700, "#868B94") ColorItem("gray600", BuyOrNotTheme.colors.gray600, "#B1B3BB") @@ -73,7 +74,7 @@ private fun ColorCatalogPreview() { Text( text = "Chromatic", style = BuyOrNotTheme.typography.headingH2Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(16.dp)) @@ -209,7 +210,7 @@ private fun TypographySection( Text( text = title, style = BuyOrNotTheme.typography.headingH2Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(12.dp)) content() @@ -281,7 +282,7 @@ private fun CombinedCatalogPreview() { Text( text = "Quick Color Samples", style = BuyOrNotTheme.typography.headingH2Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(16.dp)) @@ -301,7 +302,7 @@ private fun CombinedCatalogPreview() { Text( text = "Quick Typography Samples", style = BuyOrNotTheme.typography.headingH2Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(16.dp)) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/util/ModifierExt.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/util/ModifierExt.kt new file mode 100644 index 00000000..97f3e79f --- /dev/null +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/util/ModifierExt.kt @@ -0,0 +1,16 @@ +package com.sseotdabwa.buyornot.core.designsystem.util + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +fun Modifier.nonRippleClickable(onClick: () -> Unit): Modifier = + composed { + clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + ) + } diff --git a/core/designsystem/src/main/res/drawable-xhdpi/img_my_feed_empty.png b/core/designsystem/src/main/res/drawable-xhdpi/img_my_feed_empty.png new file mode 100644 index 00000000..673925fa Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xhdpi/img_my_feed_empty.png differ diff --git a/core/designsystem/src/main/res/drawable-xxhdpi/img_my_feed_empty.png b/core/designsystem/src/main/res/drawable-xxhdpi/img_my_feed_empty.png new file mode 100644 index 00000000..582ab16a Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxhdpi/img_my_feed_empty.png differ diff --git a/core/designsystem/src/main/res/drawable-xxxhdpi/img_my_feed_empty.png b/core/designsystem/src/main/res/drawable-xxxhdpi/img_my_feed_empty.png new file mode 100644 index 00000000..2ff4889a Binary files /dev/null and b/core/designsystem/src/main/res/drawable-xxxhdpi/img_my_feed_empty.png differ diff --git a/core/designsystem/src/main/res/drawable/ic_gallery.xml b/core/designsystem/src/main/res/drawable/ic_gallery.xml new file mode 100644 index 00000000..e13c7b72 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_gallery.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/designsystem/src/main/res/drawable/ic_link.xml b/core/designsystem/src/main/res/drawable/ic_link.xml new file mode 100644 index 00000000..0531de6f --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_link.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/designsystem/src/main/res/drawable/ic_sort.xml b/core/designsystem/src/main/res/drawable/ic_sort.xml new file mode 100644 index 00000000..46bdfc60 --- /dev/null +++ b/core/designsystem/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt index 9fa44fb2..4da291e0 100644 --- a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt +++ b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/api/FeedApiService.kt @@ -30,11 +30,12 @@ interface FeedApiService { * @param size 페이지 크기 (기본값 20, 최대 50) * @param feedStatus 피드 상태 필터 (OPEN, CLOSED / 미지정 시 전체) */ - @GET("/api/v1/feeds") + @GET("/api/v2/feeds") suspend fun getFeedList( @Query("cursor") cursor: Long? = null, @Query("size") size: Int = 20, @Query("feedStatus") feedStatus: String? = null, + @Query("category") category: List? = null, ): BaseResponse /** @@ -42,7 +43,7 @@ interface FeedApiService { * * @param feedId 조회할 피드 ID */ - @GET("/api/v1/feeds/{feedId}") + @GET("/api/v2/feeds/{feedId}") suspend fun getFeed( @Path("feedId") feedId: Long, ): BaseResponse @@ -54,7 +55,7 @@ interface FeedApiService { * @param size 페이지 크기 (기본값 20, 최대 50) * @param feedStatus 피드 상태 필터 (OPEN, CLOSED / 미지정 시 전체) */ - @GET("/api/v1/users/me/feeds") + @GET("/api/v2/users/me/feeds") suspend fun getMyFeeds( @Query("cursor") cursor: Long? = null, @Query("size") size: Int = 20, @@ -78,7 +79,7 @@ interface FeedApiService { @Body body: RequestBody, ): Response - @POST("/api/v1/feeds") + @POST("/api/v2/feeds") suspend fun createFeed( @Body request: FeedRequest, ): BaseResponse diff --git a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/request/FeedRequest.kt b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/request/FeedRequest.kt index 21e678b2..8c3c26f6 100644 --- a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/request/FeedRequest.kt +++ b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/request/FeedRequest.kt @@ -3,6 +3,16 @@ package com.sseotdabwa.buyornot.core.network.dto.request import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Serializable +data class FeedImageRequest( + @SerialName("s3ObjectKey") + val s3ObjectKey: String, + @SerialName("imageWidth") + val imageWidth: Int, + @SerialName("imageHeight") + val imageHeight: Int, +) + @Serializable data class FeedRequest( @SerialName("category") @@ -11,10 +21,10 @@ data class FeedRequest( val price: Int, @SerialName("content") val content: String, - @SerialName("s3ObjectKey") - val s3ObjectKey: String, - @SerialName("imageWidth") - val imageWidth: Int, - @SerialName("imageHeight") - val imageHeight: Int, + @SerialName("images") + val images: List, + @SerialName("link") + val link: String? = null, + @SerialName("title") + val title: String? = null, ) diff --git a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/FeedListResponse.kt b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/FeedListResponse.kt index e5c47259..d81a7fbd 100644 --- a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/FeedListResponse.kt +++ b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/FeedListResponse.kt @@ -13,6 +13,18 @@ data class FeedListResponse( val hasNext: Boolean, ) +@Serializable +data class FeedImageDto( + @SerialName("s3ObjectKey") + val s3ObjectKey: String, + @SerialName("imageUrl") + val imageUrl: String, + @SerialName("imageWidth") + val imageWidth: Int, + @SerialName("imageHeight") + val imageHeight: Int, +) + @Serializable data class FeedItemDto( @SerialName("feedId") @@ -31,14 +43,12 @@ data class FeedItemDto( val totalCount: Int, @SerialName("feedStatus") val feedStatus: String, - @SerialName("s3ObjectKey") - val s3ObjectKey: String, - @SerialName("viewUrl") - val viewUrl: String, - @SerialName("imageWidth") - val imageWidth: Int, - @SerialName("imageHeight") - val imageHeight: Int, + @SerialName("images") + val images: List, + @SerialName("link") + val link: String? = null, + @SerialName("title") + val title: String? = null, @SerialName("author") val author: AuthorDto, @SerialName("createdAt") diff --git a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/NotificationResponse.kt b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/NotificationResponse.kt index 51387faf..806f4eb0 100644 --- a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/NotificationResponse.kt +++ b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/dto/response/NotificationResponse.kt @@ -14,4 +14,5 @@ data class NotificationResponse( val resultPercent: Int, val resultLabel: String, val viewUrl: String, + val feedTitle: String?, ) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 30371fd2..ec65d7f8 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("buyornot.android.library") alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -14,10 +15,12 @@ android { dependencies { implementation(projects.core.designsystem) implementation(platform(libs.androidx.compose.bom)) + implementation(libs.coil.compose) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.lifecycle.viewmodel.ktx) debugImplementation(libs.androidx.compose.ui.tooling) + testImplementation(libs.junit) } diff --git a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerNavigation.kt b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerNavigation.kt new file mode 100644 index 00000000..8c1aa21f --- /dev/null +++ b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerNavigation.kt @@ -0,0 +1,39 @@ +package com.sseotdabwa.buyornot.core.ui.imageviewer + +import androidx.compose.ui.window.DialogProperties +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.dialog +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable + +@Serializable +data class ImageViewerRoute( + val imageUrls: List, + val initialPage: Int = 0, +) + +fun NavController.navigateToImageViewer( + imageUrls: List, + initialPage: Int = 0, +) { + navigate(ImageViewerRoute(imageUrls = imageUrls, initialPage = initialPage)) +} + +fun NavGraphBuilder.imageViewerScreen(onBackClick: () -> Unit) { + dialog( + dialogProperties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + dismissOnClickOutside = false, + ), + ) { backStackEntry -> + val route = backStackEntry.toRoute() + ImageViewerScreen( + imageUrls = route.imageUrls, + initialPage = route.initialPage, + onBackClick = onBackClick, + ) + } +} diff --git a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerScreen.kt b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerScreen.kt new file mode 100644 index 00000000..5cbd6926 --- /dev/null +++ b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ImageViewerScreen.kt @@ -0,0 +1,366 @@ +package com.sseotdabwa.buyornot.core.ui.imageviewer + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector2D +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroid +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +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.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons +import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector +import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs + +private const val MAX_SCALE = 3f + +internal fun computeMaxOffset( + containerWidth: Int, + containerHeight: Int, + imageWidth: Float, + imageHeight: Float, + scale: Float, +): Pair? { + if (containerWidth == 0 || + containerHeight == 0 || + !imageWidth.isFinite() || + !imageHeight.isFinite() || + imageWidth <= 0f || + imageHeight <= 0f + ) { + return null + } + val ratio = minOf(containerWidth.toFloat() / imageWidth, containerHeight.toFloat() / imageHeight) + val maxX = maxOf(0f, (imageWidth * ratio * scale - containerWidth) / 2) + val maxY = maxOf(0f, (imageHeight * ratio * scale - containerHeight) / 2) + return Pair(maxX, maxY) +} + +internal fun computeFocalOffset( + currentOffset: Offset, + centroid: Offset, + containerWidth: Int, + containerHeight: Int, + currentScale: Float, + newScale: Float, + pan: Offset, +): Offset { + if (newScale <= 1f) return Offset.Zero + val actualZoom = if (currentScale > 0f) newScale / currentScale else newScale + val centroidFromCenter = centroid - Offset(containerWidth / 2f, containerHeight / 2f) + return centroidFromCenter * (1 - actualZoom) + currentOffset * actualZoom + pan +} + +@Composable +fun ImageViewerScreen( + imageUrls: List, + initialPage: Int, + onBackClick: () -> Unit, +) { + val pagerState = + rememberPagerState( + initialPage = initialPage, + pageCount = { imageUrls.size }, + ) + val scope = rememberCoroutineScope() + + Column( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .statusBarsPadding() + .height(60.dp), + contentAlignment = Alignment.CenterStart, + ) { + Box( + modifier = + Modifier + .align(Alignment.TopStart) + .padding(start = 10.dp, top = 10.dp) + .size(40.dp) + .clickable(onClick = onBackClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = BuyOrNotIcons.Close.asImageVector(), + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + userScrollEnabled = false, + ) { page -> + ZoomableImage(imageUrl = imageUrls[page]) + } + + if (pagerState.currentPage > 0) { + Box( + modifier = + Modifier + .align(Alignment.CenterStart) + .padding(start = 10.dp) + .size(30.dp) + .background(Color.Black.copy(alpha = 0.4f), CircleShape) + .clickable { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage - 1) } }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = BuyOrNotIcons.ArrowLeft.asImageVector(), + contentDescription = "이전 사진", + tint = BuyOrNotTheme.colors.gray0, + modifier = Modifier.size(14.dp), + ) + } + } + + if (pagerState.currentPage < imageUrls.size - 1) { + Box( + modifier = + Modifier + .align(Alignment.CenterEnd) + .padding(end = 10.dp) + .size(30.dp) + .background(Color.Black.copy(alpha = 0.4f), CircleShape) + .clickable { scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) } }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = BuyOrNotIcons.ArrowRight.asImageVector(), + contentDescription = "다음 사진", + tint = BuyOrNotTheme.colors.gray0, + modifier = Modifier.size(14.dp), + ) + } + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .height(60.dp), + ) + } +} + +@Composable +private fun ZoomableImage(imageUrl: String) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + var containerSize by remember { mutableStateOf(IntSize.Zero) } + var imageIntrinsicSize by remember { mutableStateOf(Size.Unspecified) } + var pinchVersion by remember { mutableIntStateOf(0) } + val scope = rememberCoroutineScope() + + LaunchedEffect(pinchVersion) { + if (pinchVersion > 0) { + animateResetZoom( + currentScale = scale, + currentOffset = offset, + onScaleChange = { scale = it }, + onOffsetChange = { offset = it }, + ) + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .onSizeChanged { containerSize = it } + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var didPinch = false + + do { + val event = awaitPointerEvent() + val zoom = event.calculateZoom() + val pan = event.calculatePan() + val centroid = event.calculateCentroid(useCurrent = false) + + if (abs(zoom - 1f) > 0.0001f || (pan != Offset.Zero && scale > 1f)) { + if (abs(zoom - 1f) > 0.0001f) didPinch = true + + val newScale = (scale * zoom).coerceIn(1f, MAX_SCALE) + val rawOffset = + computeFocalOffset( + currentOffset = offset, + centroid = centroid, + containerWidth = containerSize.width, + containerHeight = containerSize.height, + currentScale = scale, + newScale = newScale, + pan = pan, + ) + scale = newScale + offset = computeMaxOffset( + containerWidth = containerSize.width, + containerHeight = containerSize.height, + imageWidth = imageIntrinsicSize.width, + imageHeight = imageIntrinsicSize.height, + scale = newScale, + )?.let { (maxX, maxY) -> + Offset(rawOffset.x.coerceIn(-maxX, maxX), rawOffset.y.coerceIn(-maxY, maxY)) + } ?: rawOffset + event.changes.forEach { if (it.positionChanged()) it.consume() } + } + } while (event.changes.any { it.pressed }) + + if (didPinch) pinchVersion++ + } + }.pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scope.launch { + if (scale > 1f) { + animateResetZoom( + currentScale = scale, + currentOffset = offset, + onScaleChange = { scale = it }, + onOffsetChange = { offset = it }, + ) + } else { + scale = 2f + offset = Offset.Zero + } + } + }, + ) + }.graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + }, + contentAlignment = Alignment.Center, + ) { + val isPreview = LocalInspectionMode.current + if (isPreview) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.White), + ) + } else { + AsyncImage( + model = imageUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + onSuccess = { state -> + val drawable = state.result.drawable + imageIntrinsicSize = + Size( + drawable.intrinsicWidth.toFloat(), + drawable.intrinsicHeight.toFloat(), + ) + }, + ) + } + } +} + +private suspend fun animateResetZoom( + currentScale: Float, + currentOffset: Offset, + onScaleChange: (Float) -> Unit, + onOffsetChange: (Offset) -> Unit, +) { + coroutineScope { + val scaleAnim = Animatable(currentScale) + val offsetAnim = + Animatable( + initialValue = currentOffset, + typeConverter = + TwoWayConverter( + convertToVector = { AnimationVector2D(it.x, it.y) }, + convertFromVector = { Offset(it.v1, it.v2) }, + ), + ) + launch { scaleAnim.animateTo(1f, spring()) { onScaleChange(value) } } + launch { offsetAnim.animateTo(Offset.Zero, spring()) { onOffsetChange(value) } } + } +} + +@Preview(showBackground = true) +@Composable +private fun ImageViewerScreenPreview() { + BuyOrNotTheme { + ImageViewerScreen( + imageUrls = listOf("url1", "url2", "url3"), + initialPage = 0, + onBackClick = {}, + ) + } +} + +@Preview(showBackground = true, name = "ImageViewerScreen - 2페이지") +@Composable +private fun ImageViewerScreenSecondPagePreview() { + BuyOrNotTheme { + ImageViewerScreen( + imageUrls = listOf("url1", "url2", "url3"), + initialPage = 1, + onBackClick = {}, + ) + } +} diff --git a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt index cc46178a..fe67d0ee 100644 --- a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt +++ b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewNavigation.kt @@ -2,24 +2,25 @@ package com.sseotdabwa.buyornot.core.ui.webview import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavType import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import java.net.URLDecoder -import java.net.URLEncoder - -private const val WEBVIEW_ROUTE = "webview" +import androidx.navigation.toRoute +import kotlinx.serialization.Serializable internal const val TERMS_URL = "https://littlemoom.notion.site/buy-or-not-service-term?pvs=143" internal const val PRIVACY_URL = "https://littlemoom.notion.site/buy-or-not-privacy-term?pvs=143" internal const val FEEDBACK_URL = "https://docs.google.com/forms/d/e/1FAIpQLScG0GStvzog1HVZjAP9OpHl85azcez2OdAr7YwrI7rvCqInsg/viewform" -private fun NavController.navigateToWebView( +@Serializable +data class WebViewRoute( + val title: String, + val url: String, +) + +fun NavController.navigateToWebView( title: String, url: String, ) { - val encodedUrl = URLEncoder.encode(url, "UTF-8") - this.navigate("$WEBVIEW_ROUTE?title=$title&url=$encodedUrl") + navigate(WebViewRoute(title = title, url = url)) } fun NavController.navigateToTerms() { @@ -35,19 +36,11 @@ fun NavController.navigateToFeedBack() { } fun NavGraphBuilder.webViewScreen(onBackClick: () -> Unit) { - composable( - route = "$WEBVIEW_ROUTE?title={title}&url={url}", - arguments = - listOf( - navArgument("title") { type = NavType.StringType }, - navArgument("url") { type = NavType.StringType }, - ), - ) { backStackEntry -> - val title = backStackEntry.arguments?.getString("title") ?: "" - val url = backStackEntry.arguments?.getString("url") ?: "" - WebViewRoute( - title = title, - url = URLDecoder.decode(url, "UTF-8"), + composable { backStackEntry -> + val route = backStackEntry.toRoute() + WebViewScreen( + title = route.title, + url = route.url, onBackClick = onBackClick, ) } diff --git a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewScreen.kt b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewScreen.kt index e3b1477d..86907c8c 100644 --- a/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewScreen.kt +++ b/core/ui/src/main/java/com/sseotdabwa/buyornot/core/ui/webview/WebViewScreen.kt @@ -10,15 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import com.sseotdabwa.buyornot.core.designsystem.components.BackTopBarWithTitle -@Composable -fun WebViewRoute( - title: String, - url: String, - onBackClick: () -> Unit, -) { - WebViewScreen(title = title, url = url, onBackClick = onBackClick) -} - @Composable fun WebViewScreen( title: String, diff --git a/core/ui/src/test/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ComputeMaxOffsetTest.kt b/core/ui/src/test/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ComputeMaxOffsetTest.kt new file mode 100644 index 00000000..c58cb302 --- /dev/null +++ b/core/ui/src/test/java/com/sseotdabwa/buyornot/core/ui/imageviewer/ComputeMaxOffsetTest.kt @@ -0,0 +1,162 @@ +package com.sseotdabwa.buyornot.core.ui.imageviewer + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ComputeMaxOffsetTest { + @Test + fun `square image square container scale 2 returns half container size`() { + val (maxX, maxY) = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = 400f, + imageHeight = 400f, + scale = 2f, + )!! + assertEquals(200f, maxX, 0.01f) + assertEquals(200f, maxY, 0.01f) + } + + @Test + fun `scale 1 returns zero max offset`() { + val (maxX, maxY) = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = 400f, + imageHeight = 400f, + scale = 1f, + )!! + assertEquals(0f, maxX, 0.01f) + assertEquals(0f, maxY, 0.01f) + } + + // ratio = min(400/800, 400/400) = 0.5 + // renderedW = 400, renderedH = 200 + // scale=2f → maxX = (400*2-400)/2 = 200, maxY = (200*2-400)/2 = 0 + @Test + fun `wide image letterbox vertical returns zero vertical max offset at scale 2`() { + val (maxX, maxY) = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = 800f, + imageHeight = 400f, + scale = 2f, + )!! + assertEquals(200f, maxX, 0.01f) + assertEquals(0f, maxY, 0.01f) + } + + @Test + fun `zero container returns null`() { + val result = + computeMaxOffset( + containerWidth = 0, + containerHeight = 400, + imageWidth = 400f, + imageHeight = 400f, + scale = 2f, + ) + assertEquals(null, result) + } + + @Test + fun `NaN image size returns null`() { + val result = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = Float.NaN, + imageHeight = 400f, + scale = 2f, + ) + assertEquals(null, result) + } + + @Test + fun `zero container height returns null`() { + val result = + computeMaxOffset( + containerWidth = 400, + containerHeight = 0, + imageWidth = 400f, + imageHeight = 400f, + scale = 2f, + ) + assertEquals(null, result) + } + + @Test + fun `infinite image size returns null`() { + val result = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = Float.POSITIVE_INFINITY, + imageHeight = 400f, + scale = 2f, + ) + assertEquals(null, result) + } + + // ratio = min(400/400, 400/800) = 0.5 + // renderedW = 400*0.5 = 200, renderedH = 800*0.5 = 400 + // scale=2f → maxX = (200*2 - 400)/2 = 0, maxY = (400*2 - 400)/2 = 200 + @Test + fun `tall image letterbox horizontal returns zero horizontal max offset at scale 2`() { + val (maxX, maxY) = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = 400f, + imageHeight = 800f, + scale = 2f, + )!! + assertEquals(0f, maxX, 0.01f) + assertEquals(200f, maxY, 0.01f) + } + + // renderedW = renderedH = 400 (ratio=1) + // maxX = (400*3 - 400)/2 = 400, maxY = 400 + @Test + fun `scale at max returns correct max offset`() { + val (maxX, maxY) = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = 400f, + imageHeight = 400f, + scale = 3f, + )!! + assertEquals(400f, maxX, 0.01f) + assertEquals(400f, maxY, 0.01f) + } + + @Test + fun `zero image width returns null`() { + val result = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = 0f, + imageHeight = 400f, + scale = 2f, + ) + assertEquals(null, result) + } + + @Test + fun `negative image width returns null`() { + val result = + computeMaxOffset( + containerWidth = 400, + containerHeight = 400, + imageWidth = -1f, + imageHeight = 400f, + scale = 2f, + ) + assertEquals(null, result) + } +} diff --git a/core/ui/src/test/java/com/sseotdabwa/buyornot/core/ui/imageviewer/FocalOffsetTest.kt b/core/ui/src/test/java/com/sseotdabwa/buyornot/core/ui/imageviewer/FocalOffsetTest.kt new file mode 100644 index 00000000..ac44500a --- /dev/null +++ b/core/ui/src/test/java/com/sseotdabwa/buyornot/core/ui/imageviewer/FocalOffsetTest.kt @@ -0,0 +1,133 @@ +package com.sseotdabwa.buyornot.core.ui.imageviewer + +import androidx.compose.ui.geometry.Offset +import org.junit.Assert.assertEquals +import org.junit.Test + +class FocalOffsetTest { + private fun assertOffsetEquals( + expected: Offset, + actual: Offset, + delta: Float = 0.01f, + ) { + assertEquals(expected.x, actual.x, delta) + assertEquals(expected.y, actual.y, delta) + } + + // centroid가 컨테이너 중앙(200,200)이면 중앙 기준 zoom → offset 변화 없음 (pan=Zero) + @Test + fun `centroid at center zoom 2x returns zero offset`() { + val result = + computeFocalOffset( + currentOffset = Offset.Zero, + centroid = Offset(200f, 200f), + containerWidth = 400, + containerHeight = 400, + currentScale = 1f, + newScale = 2f, + pan = Offset.Zero, + ) + assertOffsetEquals(Offset.Zero, result) + } + + // centroid가 좌상단(0,0)이면 좌상단 고정으로 확대 → offset이 우하단으로 이동 + // c = (-200, -200), actualZoom=2 + // result = (-200,-200)*(1-2) + (0,0)*2 + (0,0) = (200,200) + @Test + fun `centroid at top-left zoom 2x shifts offset to bottom-right`() { + val result = + computeFocalOffset( + currentOffset = Offset.Zero, + centroid = Offset(0f, 0f), + containerWidth = 400, + containerHeight = 400, + currentScale = 1f, + newScale = 2f, + pan = Offset.Zero, + ) + assertOffsetEquals(Offset(200f, 200f), result) + } + + // newScale = 1f이면 반드시 Offset.Zero 반환 + @Test + fun `newScale 1f returns zero regardless of other params`() { + val result = + computeFocalOffset( + currentOffset = Offset(100f, 50f), + centroid = Offset(50f, 50f), + containerWidth = 400, + containerHeight = 400, + currentScale = 2f, + newScale = 1f, + pan = Offset(10f, 10f), + ) + assertOffsetEquals(Offset.Zero, result) + } + + // centroid=중앙, zoom=1, pan=(10,5) → offset이 pan만큼 이동 + @Test + fun `pan offset applied correctly`() { + val result = + computeFocalOffset( + currentOffset = Offset.Zero, + centroid = Offset(200f, 200f), + containerWidth = 400, + containerHeight = 400, + currentScale = 2f, + newScale = 2f, + pan = Offset(10f, 5f), + ) + assertOffsetEquals(Offset(10f, 5f), result) + } + + // 이미 offset이 있는 상태에서 centroid=중앙으로 zoom → offset이 actualZoom 배로 증폭 + // c = Zero, actualZoom=2, currentOffset=(50,30) + // result = Zero*(1-2) + (50,30)*2 + Zero = (100,60) + @Test + fun `existing offset amplified by zoom when centroid at center`() { + val result = + computeFocalOffset( + currentOffset = Offset(50f, 30f), + centroid = Offset(200f, 200f), + containerWidth = 400, + containerHeight = 400, + currentScale = 1f, + newScale = 2f, + pan = Offset.Zero, + ) + assertOffsetEquals(Offset(100f, 60f), result) + } + + // newScale < 1f (예: 0.5f) 도 Offset.Zero 반환 — <= 1f 경계 하한 검증 + @Test + fun `newScale below 1f returns zero`() { + val result = + computeFocalOffset( + currentOffset = Offset(100f, 50f), + centroid = Offset(200f, 200f), + containerWidth = 400, + containerHeight = 400, + currentScale = 1f, + newScale = 0.5f, + pan = Offset.Zero, + ) + assertOffsetEquals(Offset.Zero, result) + } + + // actualZoom = 1 (currentScale == newScale, scale > 1f) 이면 결과 = currentOffset + pan + // c = Zero (centroid=중앙), actualZoom=1 → result = Zero*(1-1) + (50,30)*1 + (10,5) = (60,35) + @Test + fun `no zoom with pan returns offset plus pan`() { + val result = + computeFocalOffset( + currentOffset = Offset(50f, 30f), + centroid = Offset(200f, 200f), + containerWidth = 400, + containerHeight = 400, + currentScale = 2f, + newScale = 2f, + pan = Offset(10f, 5f), + ) + assertOffsetEquals(Offset(60f, 35f), result) + } +} diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt index c4bbdbb4..1fdb7f21 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt @@ -1,10 +1,21 @@ package com.sseotdabwa.buyornot.domain.model +/** + * 피드 이미지 도메인 모델 + */ +data class FeedImage( + val s3ObjectKey: String, + val imageUrl: String, + val imageWidth: Int, + val imageHeight: Int, +) + /** * 피드 도메인 모델 */ data class Feed( val feedId: Long, + val title: String, val content: String, val price: String, val category: FeedCategory, @@ -12,15 +23,15 @@ data class Feed( val noCount: Int, val totalCount: Int, val feedStatus: FeedStatus, - val s3ObjectKey: String, - val viewUrl: String, - val imageWidth: Int, - val imageHeight: Int, + val images: List, val author: Author, val createdAt: String, val hasVoted: Boolean, val myVoteChoice: VoteChoice?, -) + val productLink: String? = null, +) { + val viewUrls: List get() = images.map { it.imageUrl } +} /** * 작성자 정보 도메인 모델 diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Notification.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Notification.kt index 24fa088d..30415dc2 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Notification.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Notification.kt @@ -11,6 +11,7 @@ data class Notification( val resultPercent: Int, val resultLabel: String, val viewUrl: String, + val feedTitle: String, ) enum class NotificationType { diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/FeedRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/FeedRepository.kt index e7bbad48..2c9515ea 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/FeedRepository.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/FeedRepository.kt @@ -2,6 +2,7 @@ package com.sseotdabwa.buyornot.domain.repository import com.sseotdabwa.buyornot.domain.model.Feed import com.sseotdabwa.buyornot.domain.model.FeedCategory +import com.sseotdabwa.buyornot.domain.model.FeedImage import com.sseotdabwa.buyornot.domain.model.UploadInfo import com.sseotdabwa.buyornot.domain.model.VoteChoice import com.sseotdabwa.buyornot.domain.model.VoteResult @@ -28,6 +29,7 @@ interface FeedRepository { cursor: Long? = null, size: Int = 20, feedStatus: String? = null, + category: List? = null, ): FeedList /** @@ -67,9 +69,9 @@ interface FeedRepository { category: FeedCategory, price: Int, content: String, - s3ObjectKey: String, - imageWidth: Int, - imageHeight: Int, + images: List, + title: String? = null, + link: String? = null, ): Long /** diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt index 455378a8..8ea11417 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/navigation/AuthNavigation.kt @@ -3,37 +3,23 @@ package com.sseotdabwa.buyornot.feature.auth.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable -import com.sseotdabwa.buyornot.feature.auth.ui.AuthRoute -import com.sseotdabwa.buyornot.feature.auth.ui.SplashRoute +import kotlinx.serialization.Serializable +import com.sseotdabwa.buyornot.feature.auth.ui.AuthRoute as AuthScreen +import com.sseotdabwa.buyornot.feature.auth.ui.SplashRoute as SplashScreen -/** - * 스플래시 화면의 네비게이션 라우트 상수 - */ -const val SPLASH_ROUTE = "splash" +@Serializable +data object SplashRoute -/** - * 인증(로그인) 화면의 네비게이션 라우트 상수 - * - * NavHost에서 인증 화면으로 이동할 때 사용되는 라우트 문자열입니다. - */ -const val AUTH_ROUTE = "auth" +@Serializable +data object AuthRoute -/** - * NavGraphBuilder의 확장 함수 - 스플래시 화면을 네비게이션 그래프에 추가 - * - * 앱 최초 진입 시 표시되는 스플래시 화면을 등록합니다. - * 2.3초 후 자동으로 로그인 상태를 확인하여 홈 또는 로그인 화면으로 이동합니다. - * - * @param onNavigateToLogin 로그인 화면으로 이동할 때 실행될 콜백 - * @param onNavigateToHome 홈 화면으로 이동할 때 실행될 콜백 - */ fun NavGraphBuilder.splashScreen( onNavigateToLogin: () -> Unit, onNavigateToHome: () -> Unit, onFinish: () -> Unit, ) { - composable(route = SPLASH_ROUTE) { - SplashRoute( + composable { + SplashScreen( onNavigateToLogin = onNavigateToLogin, onNavigateToHome = onNavigateToHome, onFinish = onFinish, @@ -41,38 +27,13 @@ fun NavGraphBuilder.splashScreen( } } -/** - * NavGraphBuilder의 확장 함수 - 인증 화면을 네비게이션 그래프에 추가 - * - * Compose Navigation을 사용하여 인증(로그인) 화면을 네비게이션 그래프에 등록합니다. - * 소셜 로그인 기능을 포함한 로그인 화면이 표시되며, - * 약관 및 개인정보처리방침은 AuthRoute 내부에서 UriHandler로 처리됩니다. - * - * 사용 예시: - * ``` - * NavHost(navController = navController, startDestination = SPLASH_ROUTE) { - * splashScreen( - * onNavigateToLogin = { navController.navigate(AUTH_ROUTE) } - * ) - * authScreen( - * onGoogleLoginClick = { /* 구글 로그인 처리 */ }, - * onKakaoLoginClick = { /* 카카오 로그인 처리 */ } - * ) - * } - * ``` - * - * @param onGoogleLoginClick 구글 로그인 버튼 클릭 시 실행될 콜백 - * @param onKakaoLoginClick 카카오 로그인 버튼 클릭 시 실행될 콜백 - * @param onTermsClick 서비스 약관 링크 클릭 콜백 - * @param onPrivacyClick 개인정보처리방침 링크 클릭 콜백 - */ fun NavGraphBuilder.authScreen( onLoginSuccess: () -> Unit, onTermsClick: () -> Unit, onPrivacyClick: () -> Unit, ) { - composable(route = AUTH_ROUTE) { - AuthRoute( + composable { + AuthScreen( onLoginSuccess = onLoginSuccess, onTermsClick = onTermsClick, onPrivacyClick = onPrivacyClick, @@ -80,29 +41,16 @@ fun NavGraphBuilder.authScreen( } } -/** - * 스플래시에서 로그인 화면으로 이동하는 확장 함수 - * - * popUpTo를 사용하여 스플래시 화면을 백스택에서 제거합니다. - * 사용자가 로그인 화면에서 뒤로가기 버튼을 눌러도 스플래시로 돌아가지 않도록 합니다. - */ fun NavHostController.navigateToLogin() { - navigate(AUTH_ROUTE) { - popUpTo(SPLASH_ROUTE) { + navigate(AuthRoute) { + popUpTo { inclusive = true } } } -/** - * 앱의 어느 화면에서든 로그인 화면으로 이동하는 확장 함수 - * - * popUpTo(graph.id) { inclusive = true }를 사용하여 - * 현재 네비게이션 스택을 모두 지우고 로그인 화면으로 이동합니다. - * 주로 토큰 만료 등 강제 로그아웃이 필요할 때 사용됩니다. - */ fun NavHostController.navigateForceToLogin() { - navigate(AUTH_ROUTE) { + navigate(AuthRoute) { popUpTo(graph.id) { inclusive = true } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt index 3038ac6f..b4d601b1 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt @@ -154,7 +154,7 @@ private fun LoginInteractionSection( Text( text = "현명한 소비를 위한\n집단지성 비교 방법", style = BuyOrNotTheme.typography.headingH1SemiBold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, textAlign = TextAlign.Center, ) @@ -164,7 +164,7 @@ private fun LoginInteractionSection( text = "구글 계정으로 시작하기", iconResId = BuyOrNotIcons.GoogleLogo.resId, containerColor = Color.White, - contentColor = BuyOrNotTheme.colors.gray900, + contentColor = BuyOrNotTheme.colors.gray950, hasBorder = true, enabled = !isLoading, onClick = onGoogleLoginClick, diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt index 0435b782..c2e47144 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt @@ -9,30 +9,26 @@ import androidx.compose.animation.slideOutVertically import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.NavOptions -import androidx.navigation.NavType import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import com.sseotdabwa.buyornot.feature.home.ui.HomeRoute +import androidx.navigation.toRoute import com.sseotdabwa.buyornot.feature.home.ui.HomeTab +import kotlinx.serialization.Serializable +import com.sseotdabwa.buyornot.feature.home.ui.HomeRoute as HomeScreen -const val HOME_ROUTE = "home" -const val HOME_ROUTE_WITH_TAB = "home?tab={tab}" +@Serializable +data class HomeRoute( + val tab: String? = null, +) fun NavGraphBuilder.homeScreen( onLoginClick: () -> Unit = {}, onNotificationClick: () -> Unit = {}, onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, + onLinkClick: (url: String) -> Unit = {}, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { - composable( - route = HOME_ROUTE_WITH_TAB, - arguments = - listOf( - navArgument("tab") { - type = NavType.StringType - nullable = true - }, - ), + composable( enterTransition = { slideInVertically( initialOffsetY = { (it * 0.15f).toInt() }, @@ -52,30 +48,32 @@ fun NavGraphBuilder.homeScreen( ) + fadeOut(animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)) }, ) { backStackEntry -> - val tabName = backStackEntry.arguments?.getString("tab") + val route = backStackEntry.toRoute() val initialTab = - when (tabName) { + when (route.tab) { HomeTab.MY_FEED.name -> HomeTab.MY_FEED else -> HomeTab.FEED } - HomeRoute( + HomeScreen( onLoginClick = onLoginClick, onNotificationClick = onNotificationClick, onProfileClick = onProfileClick, onUploadClick = onUploadClick, + onLinkClick = onLinkClick, + onImageClick = onImageClick, initialTab = initialTab, ) } } fun NavHostController.navigateToHome(navOptions: NavOptions? = null) { - this.navigate(HOME_ROUTE, navOptions) + navigate(HomeRoute(), navOptions) } fun NavHostController.navigateToHomeWithTab( tab: HomeTab, navOptions: NavOptions? = null, ) { - this.navigate("home?tab=${tab.name}", navOptions) + navigate(HomeRoute(tab = tab.name), navOptions) } diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt index 7086a607..0e83ef98 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt @@ -3,6 +3,7 @@ package com.sseotdabwa.buyornot.feature.home.ui import androidx.compose.runtime.Immutable import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio import com.sseotdabwa.buyornot.core.designsystem.icon.IconResource +import com.sseotdabwa.buyornot.domain.model.FeedCategory import com.sseotdabwa.buyornot.domain.model.UserType /** @@ -31,10 +32,11 @@ data class FeedItem( val nickname: String, val category: String, val createdAt: String, + val title: String, val content: String, - val productImageUrl: String, + val productImageUrls: List, val price: String, - val imageAspectRatio: ImageAspectRatio, + val imageAspectRatios: List, val isVoteEnded: Boolean, val userVotedOptionIndex: Int?, val buyVoteCount: Int, @@ -42,6 +44,7 @@ data class FeedItem( val totalVoteCount: Int, val isOwner: Boolean, val authorUserId: Long, + val productLink: String? = null, ) /** @@ -65,6 +68,8 @@ data class HomeUiState( val selectedFilter: FilterChip = FilterChip.ALL, val isBannerVisible: Boolean = true, val voterProfileImageUrl: String = "", + val allFeeds: List = emptyList(), + val selectedCategories: Set = emptySet(), val feeds: List = emptyList(), val isLoading: Boolean = true, val isRefreshing: Boolean = false, @@ -77,6 +82,7 @@ data class HomeUiState( val showBlockDialog: Boolean = false, val blockingNickname: String? = null, val blockingUserId: Long? = null, + val showSortSheet: Boolean = false, ) /** @@ -125,6 +131,16 @@ sealed interface HomeIntent { data object DismissBlockDialog : HomeIntent data object OnBlockConfirmed : HomeIntent + + data class OnCategoryToggled( + val category: FeedCategory, + ) : HomeIntent + + data object OnAllCategorySelected : HomeIntent + + data object ShowSortSheet : HomeIntent + + data object DismissSortSheet : HomeIntent } /** diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt index d6c892e5..07bd6469 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt @@ -1,20 +1,29 @@ package com.sseotdabwa.buyornot.feature.home.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInCubic +import androidx.compose.animation.core.EaseOutCubic +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.interaction.MutableInteractionSource 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.rememberLazyListState @@ -22,28 +31,40 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf 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.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.sseotdabwa.buyornot.core.designsystem.components.ButtonSize import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotButtonDefaults import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotChip import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotDivider import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotDividerSize -import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotEmptyView import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotErrorView +import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotIconChip import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotTab import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotTabRow @@ -52,13 +73,19 @@ import com.sseotdabwa.buyornot.core.designsystem.components.FabOption import com.sseotdabwa.buyornot.core.designsystem.components.FeedCard import com.sseotdabwa.buyornot.core.designsystem.components.GuestTopBar import com.sseotdabwa.buyornot.core.designsystem.components.HomeTopBar +import com.sseotdabwa.buyornot.core.designsystem.components.NeutralButton +import com.sseotdabwa.buyornot.core.designsystem.components.OptionSheet import com.sseotdabwa.buyornot.core.designsystem.components.showBuyOrNotSnackBar import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons +import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotImgs import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme +import com.sseotdabwa.buyornot.domain.model.FeedCategory import com.sseotdabwa.buyornot.domain.model.UserType import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.launch /** * 홈 화면 루트 컴포저블 @@ -77,6 +104,8 @@ fun HomeRoute( onNotificationClick: () -> Unit = {}, onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, + onLinkClick: (url: String) -> Unit = {}, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, initialTab: HomeTab = HomeTab.FEED, viewModel: HomeViewModel = hiltViewModel(), ) { @@ -115,6 +144,8 @@ fun HomeRoute( onNotificationClick = onNotificationClick, onProfileClick = onProfileClick, onUploadClick = onUploadClick, + onLinkClick = onLinkClick, + onImageClick = onImageClick, onIntent = viewModel::handleIntent, ) } @@ -130,10 +161,13 @@ fun HomeScreen( onNotificationClick: () -> Unit = {}, onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, + onLinkClick: (url: String) -> Unit = {}, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { // 화면 전용 일시적 상태 (ViewModel에서 관리하지 않음) var isFabExpanded by remember { mutableStateOf(false) } + val isEmptyViewVisible = uiState.feeds.isEmpty() && !uiState.isLoading && !uiState.hasError if (uiState.showBlockDialog && uiState.blockingNickname != null) { BuyOrNotAlertDialog( @@ -164,11 +198,13 @@ fun HomeScreen( Scaffold( snackbarHost = { BuyOrNotSnackBarHost(snackbarHostState) }, floatingActionButton = { - HomeFab( - expanded = isFabExpanded, - onExpandedChange = { isFabExpanded = it }, - onUploadClick = onUploadClick, - ) + if (!isEmptyViewVisible) { + HomeFab( + expanded = isFabExpanded, + onExpandedChange = { isFabExpanded = it }, + onUploadClick = onUploadClick, + ) + } }, containerColor = BuyOrNotTheme.colors.gray0, ) { innerPadding -> @@ -180,6 +216,8 @@ fun HomeScreen( onNotificationClick = onNotificationClick, onProfileClick = onProfileClick, onUploadClick = onUploadClick, + onLinkClick = onLinkClick, + onImageClick = onImageClick, ) FabDimOverlay( @@ -239,10 +277,7 @@ private fun HomeTabSection( } } - BuyOrNotDivider( - size = BuyOrNotDividerSize.Small, - modifier = Modifier.padding(horizontal = 20.dp), - ) + BuyOrNotDivider(size = BuyOrNotDividerSize.Small) } } @@ -317,11 +352,40 @@ private fun HomeFeedList( onNotificationClick: () -> Unit, onProfileClick: () -> Unit, onUploadClick: () -> Unit, + onLinkClick: (url: String) -> Unit, + onImageClick: (imageUrls: List, page: Int) -> Unit, modifier: Modifier = Modifier, ) { - // ViewModel에서 이미 탭과 필터에 따라 필터링된 피드를 제공 val filteredFeeds = uiState.feeds val listState = rememberLazyListState() + val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError + val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible + + var showLinkTooltip by remember { mutableStateOf(true) } + val tooltipTargetIndex = + remember(filteredFeeds) { + filteredFeeds.indexOfFirst { it.productLink != null } + } + + // 스크롤 방향 감지: 위로 스크롤하면 헤더 노출, 아래로 스크롤하면 헤더 숨김 + var isHeaderVisible by remember { mutableStateOf(true) } + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .scan(Pair(0, 0) to Pair(0, 0)) { (_, prev), curr -> prev to curr } + .collect { (prev, curr) -> + val (prevIndex, prevOffset) = prev + val (currIndex, currOffset) = curr + isHeaderVisible = + when { + currIndex == 0 && currOffset == 0 -> true + currIndex < prevIndex -> true + currIndex > prevIndex -> false + currOffset < prevOffset -> true + currOffset > prevOffset -> false + else -> isHeaderVisible + } + } + } // 무한 스크롤 구현: 리스트 끝에 도달하면 다음 페이지 로드 LaunchedEffect(listState, uiState.hasNextPage, uiState.isNextPageLoading) { @@ -331,7 +395,6 @@ private fun HomeFeedList( ?.index }.filter { lastVisibleIndex -> val totalItemsCount = listState.layoutInfo.totalItemsCount - // 전체 아이템 수에서 3개 전쯤에 도달하면 미리 로드 (0-based index) lastVisibleIndex != null && lastVisibleIndex >= totalItemsCount - 3 }.distinctUntilChanged() .collect { @@ -346,146 +409,286 @@ private fun HomeFeedList( onRefresh = { onIntent(HomeIntent.Refresh) }, modifier = modifier.fillMaxSize(), ) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = contentPadding, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - HomeTopBarSection( - userType = uiState.userType, - onLoginClick = onLoginClick, - onNotificationClick = onNotificationClick, - onProfileClick = onProfileClick, - ) - } + Column(modifier = Modifier.fillMaxSize()) { + // 고정 헤더 영역 (TopBar + FilterChipRow는 스크롤 방향에 따라 표시/숨김, Tab은 항상 고정) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = contentPadding.calculateTopPadding()) + .background(BuyOrNotTheme.colors.gray0), + ) { + AnimatedVisibility( + visible = isHeaderVisible, + enter = expandVertically(tween(300, easing = EaseOutCubic), expandFrom = Alignment.Top) + fadeIn(tween(300)), + exit = shrinkVertically(tween(200, easing = EaseInCubic), shrinkTowards = Alignment.Top) + fadeOut(tween(200)), + ) { + HomeTopBarSection( + userType = uiState.userType, + onLoginClick = onLoginClick, + onNotificationClick = onNotificationClick, + onProfileClick = onProfileClick, + ) + } - stickyHeader { HomeTabSection( userType = uiState.userType, selectedTab = uiState.selectedTab, onTabSelected = { onIntent(HomeIntent.OnTabSelected(it)) }, - modifier = Modifier.background(BuyOrNotTheme.colors.gray0), ) - } - - // 공통 필터 칩 영역 - item { - Spacer(modifier = Modifier.height(16.dp)) - FilterChipRow( - selectedFilter = uiState.selectedFilter, - onFilterSelected = { onIntent(HomeIntent.OnFilterSelected(it)) }, - ) - // 배너 (투표 피드 탭이고 isBannerVisible이 true일 때만 표시) - if (filteredFeeds.isNotEmpty() && uiState.isBannerVisible && uiState.selectedTab == HomeTab.FEED) { - Spacer(modifier = Modifier.height(16.dp)) - - HomeBanner( - modifier = Modifier.padding(horizontal = 20.dp), - onDismiss = { onIntent(HomeIntent.OnBannerDismissed) }, - onClick = onUploadClick, - ) - - Spacer(modifier = Modifier.height(16.dp)) - BuyOrNotDivider( - size = BuyOrNotDividerSize.Small, - modifier = Modifier.padding(horizontal = 20.dp), - ) + AnimatedVisibility( + visible = isHeaderVisible && !isMyFeedEmpty, + enter = expandVertically(tween(300, easing = EaseOutCubic), expandFrom = Alignment.Top) + fadeIn(tween(300)), + exit = shrinkVertically(tween(200, easing = EaseInCubic), shrinkTowards = Alignment.Top) + fadeOut(tween(200)), + ) { + Column { + Spacer(modifier = Modifier.height(10.dp)) + FilterChipRow( + selectedCategories = uiState.selectedCategories, + onAllCategorySelected = { onIntent(HomeIntent.OnAllCategorySelected) }, + onCategoryToggled = { onIntent(HomeIntent.OnCategoryToggled(it)) }, + selectedFilter = uiState.selectedFilter, + onShowSortSheet = { onIntent(HomeIntent.ShowSortSheet) }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } } } - when { - // 1. 데이터가 있으면 로딩 여부와 상관없이 최우선 노출 - filteredFeeds.isNotEmpty() -> { - // 피드 리스트 및 배너 노출 로직 (기존과 동일) - items(filteredFeeds.size, key = { index -> filteredFeeds[index].id }) { index -> - FeedItemCard( - feed = filteredFeeds[index], - voterProfileImageUrl = uiState.voterProfileImageUrl, - isGuest = uiState.userType == UserType.GUEST, - modifier = Modifier.padding(20.dp).animateItem(), - onVote = { id, opt -> onIntent(HomeIntent.OnVoteClicked(id, opt)) }, - onDelete = { id -> onIntent(HomeIntent.ShowDeleteDialog(id)) }, - onReport = { id -> onIntent(HomeIntent.OnReportClicked(id)) }, - onBlock = { id -> onIntent(HomeIntent.ShowBlockDialog(id)) }, + // 스크롤 가능한 피드 목록 + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // 배너 (투표 피드 탭이고 isBannerVisible이 true일 때만 표시) + if (filteredFeeds.isNotEmpty() && uiState.isBannerVisible && uiState.selectedTab == HomeTab.FEED) { + item { + HomeBanner( + modifier = Modifier.padding(horizontal = 20.dp), + onDismiss = { onIntent(HomeIntent.OnBannerDismissed) }, + onClick = onUploadClick, + ) + Spacer(modifier = Modifier.height(16.dp)) + BuyOrNotDivider( + size = BuyOrNotDividerSize.Small, + modifier = Modifier.padding(horizontal = 20.dp), ) } + } - // 다음 페이지 로딩 중일 때 표시 - if (uiState.isNextPageLoading) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = BuyOrNotTheme.colors.gray900, - strokeWidth = 2.dp, - ) + when { + // 1. 데이터가 있으면 로딩 여부와 상관없이 최우선 노출 + filteredFeeds.isNotEmpty() -> { + items(filteredFeeds.size, key = { index -> filteredFeeds[index].id }) { index -> + FeedItemCard( + feed = filteredFeeds[index], + voterProfileImageUrl = uiState.voterProfileImageUrl, + isGuest = uiState.userType == UserType.GUEST, + modifier = Modifier.animateItem(), + showProductLinkTooltip = showLinkTooltip && index == tooltipTargetIndex, + onVote = { id, opt -> onIntent(HomeIntent.OnVoteClicked(id, opt)) }, + onDelete = { id -> onIntent(HomeIntent.ShowDeleteDialog(id)) }, + onReport = { id -> onIntent(HomeIntent.OnReportClicked(id)) }, + onBlock = { id -> onIntent(HomeIntent.ShowBlockDialog(id)) }, + onLinkClick = onLinkClick, + onImageClick = onImageClick, + ) + } + + // 다음 페이지 로딩 중일 때 표시 + if (uiState.isNextPageLoading) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = BuyOrNotTheme.colors.gray950, + strokeWidth = 2.dp, + ) + } } } } - } - // 2. 로딩 중인 단계 (로딩이 끝나기 전까지는 Result를 판단하지 않음) - uiState.isLoading -> { - item { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = BuyOrNotTheme.colors.gray900) + // 2. 로딩 중인 단계 + uiState.isLoading -> { + item { + Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = BuyOrNotTheme.colors.gray950) + } } } - } - // 3. 로딩이 끝난 단계 (isLoading == false) - uiState.hasError -> { - // 통신 실패로 로딩이 끝난 경우 - item { - BuyOrNotErrorView( - modifier = Modifier.padding(top = 80.dp), - message = "피드를 불러오지 못했어요", - onRefreshClick = { onIntent(HomeIntent.LoadFeeds) }, - ) + // 3. 에러 + uiState.hasError -> { + item { + BuyOrNotErrorView( + modifier = Modifier.padding(top = 80.dp), + message = "피드를 불러오지 못했어요", + onRefreshClick = { onIntent(HomeIntent.LoadFeeds) }, + ) + } } - } - else -> { - // [요청사항] 통신은 성공(hasError false)했지만 데이터가 없는 경우 - item { - HomeFeedEmptyView( - modifier = Modifier.padding(top = 80.dp), - ) + else -> { + item { + if (uiState.selectedTab == HomeTab.MY_FEED) { + HomeFeedEmptyView( + modifier = Modifier.padding(top = 140.dp), + title = "아직 올린 투표가 없어요", + description = "고민되는 상품의 투표를 올려보세요!", + onUploadClick = onUploadClick, + ) + } else { + HomeFeedEmptyView( + modifier = Modifier.padding(top = 120.dp), + title = "첫번째 투표를 올려보세요!", + onUploadClick = onUploadClick, + ) + } + } } } } } + + // 투표 상태 필터 시트 (PullToRefreshBox 최상단 → full-screen dim 적용) + if (uiState.showSortSheet) { + OptionSheet( + title = "투표 상태", + options = FilterChip.entries.map { it.label }, + selectedOption = uiState.selectedFilter.label, + onOptionClick = { option -> + val filter = FilterChip.entries.first { it.label == option } + onIntent(HomeIntent.OnFilterSelected(filter)) + }, + onDismissRequest = { onIntent(HomeIntent.DismissSortSheet) }, + ) + } } } /** * 필터 칩 행 컴포넌트 + * - 맨 좌측: 정렬 아이콘 (클릭 시 투표 상태 OptionSheet 표시 요청) + * - 이후: FeedCategory 카테고리 칩 (다중 선택, 없으면 전체) */ @Composable private fun FilterChipRow( + selectedCategories: Set, + onAllCategorySelected: () -> Unit, + onCategoryToggled: (FeedCategory) -> Unit, selectedFilter: FilterChip, - onFilterSelected: (FilterChip) -> Unit, + onShowSortSheet: () -> Unit, ) { - LazyRow( + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val isChipOverlapping by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + + // LazyRow 내 index 기준: + // 0 → "전체" 칩 + // 1 + i → FeedCategory.entries[i] 칩 + fun scrollToCenter(index: Int) { + coroutineScope.launch { + val layoutInfo = listState.layoutInfo + val visibleItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } + if (visibleItem != null) { + val viewportWidth = layoutInfo.viewportSize.width + val itemCenter = visibleItem.offset + visibleItem.size / 2 + val scrollDelta = (itemCenter - viewportWidth / 2).toFloat() + listState.animateScrollBy(scrollDelta) + } else { + listState.animateScrollToItem(index) + } + } + } + + Row( modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - items(FilterChip.entries.size) { index -> - val chip = FilterChip.entries[index] - BuyOrNotChip( - text = chip.label, - isSelected = selectedFilter == chip, - onClick = { onFilterSelected(chip) }, - ) + BuyOrNotIconChip( + imageVector = BuyOrNotIcons.Sort.asImageVector(), + contentDescription = "투표 상태 필터", + onClick = onShowSortSheet, + modifier = Modifier.padding(start = 20.dp), + ) + + LazyRow( + state = listState, + modifier = + Modifier + .weight(1f) + .graphicsLayer { alpha = 0.99f } + .drawWithContent { + drawContent() + if (isChipOverlapping) { + val fadeWidth = 32.dp.toPx() + val fadeHeight = 38.dp.toPx() + drawRect( + brush = + Brush.horizontalGradient( + colorStops = + arrayOf( + 0f to Color.Transparent, + 0.5f to Color.Black.copy(alpha = 0.74f), + 1f to Color.Black, + ), + endX = fadeWidth, + ), + topLeft = + Offset( + x = 0f, + y = (size.height - fadeHeight) / 2f, + ), + size = Size(fadeWidth, fadeHeight), + blendMode = BlendMode.DstIn, + ) + } + }, + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item { + BuyOrNotChip( + text = "전체", + isSelected = selectedCategories.isEmpty(), + onClick = { + onAllCategorySelected() + scrollToCenter(0) + }, + ) + } + + items( + count = FeedCategory.entries.size, + key = { index -> FeedCategory.entries[index].name }, + ) { index -> + val category = FeedCategory.entries[index] + BuyOrNotChip( + text = category.displayName, + isSelected = category in selectedCategories, + onClick = { + onCategoryToggled(category) + scrollToCenter(1 + index) + }, + ) + } } } } @@ -499,22 +702,26 @@ private fun FeedItemCard( voterProfileImageUrl: String, isGuest: Boolean, modifier: Modifier = Modifier, + showProductLinkTooltip: Boolean = false, onVote: (String, Int) -> Unit, onDelete: (String) -> Unit, onReport: (String) -> Unit, onBlock: (String) -> Unit, + onLinkClick: (url: String) -> Unit, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { Column { FeedCard( - modifier = modifier, + modifier = modifier.padding(vertical = 26.dp), profileImageUrl = feed.profileImageUrl, nickname = feed.nickname, category = feed.category, createdAt = feed.createdAt, + title = feed.title, content = feed.content, - productImageUrl = feed.productImageUrl, + productImageUrls = feed.productImageUrls, price = feed.price, - imageAspectRatio = feed.imageAspectRatio, + imageAspectRatios = feed.imageAspectRatios, isVoteEnded = feed.isVoteEnded, userVotedOptionIndex = feed.userVotedOptionIndex, buyVoteCount = feed.buyVoteCount, @@ -529,6 +736,10 @@ private fun FeedItemCard( onReportClick = { onReport(feed.id) }, onBlockClick = { onBlock(feed.id) }, showMoreButton = !isGuest, + productLink = feed.productLink, + onLinkClick = onLinkClick, + showProductLinkTooltip = showProductLinkTooltip, + onImageClick = onImageClick, ) BuyOrNotDivider( @@ -539,13 +750,51 @@ private fun FeedItemCard( } @Composable -fun HomeFeedEmptyView(modifier: Modifier = Modifier) { - BuyOrNotEmptyView( +fun HomeFeedEmptyView( + title: String, + onUploadClick: () -> Unit, + modifier: Modifier = Modifier, + description: String? = null, +) { + Column( modifier = modifier, - title = "아직 올린 투표가 없어요", - description = "고민되는 상품의 투표를 올려보세요!", - image = BuyOrNotIcons.NoVote.resId, - ) + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = BuyOrNotImgs.MyFeedEmpty.resId), + contentDescription = null, + modifier = + Modifier + .width(240.dp) + .height(180.dp), + contentScale = ContentScale.Fit, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = title, + style = BuyOrNotTheme.typography.titleT1Bold, + color = BuyOrNotTheme.colors.gray800, + ) + + if (description != null) { + Text( + modifier = Modifier.padding(top = 6.dp), + text = description, + style = BuyOrNotTheme.typography.bodyB5Medium, + color = BuyOrNotTheme.colors.gray600, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + NeutralButton( + text = "투표 등록하기", + size = ButtonSize.Small, + onClick = onUploadClick, + ) + } } @Preview(name = "HomeScreen Preview", showBackground = false) @@ -553,7 +802,41 @@ fun HomeFeedEmptyView(modifier: Modifier = Modifier) { private fun HomeScreenPreview() { BuyOrNotTheme { HomeScreen( - uiState = HomeUiState(), + uiState = HomeUiState(userType = UserType.SOCIAL), + onIntent = {}, + ) + } +} + +@Preview(name = "HomeScreen - 투표 피드 빈 상태", showBackground = true) +@Composable +private fun HomeScreenEmptyFeedPreview() { + BuyOrNotTheme { + HomeScreen( + uiState = + HomeUiState( + isLoading = false, + userType = UserType.SOCIAL, + feeds = emptyList(), + selectedTab = HomeTab.FEED, + ), + onIntent = {}, + ) + } +} + +@Preview(name = "HomeScreen - 내 투표 빈 상태", showBackground = true) +@Composable +private fun HomeScreenEmptyMyFeedPreview() { + BuyOrNotTheme { + HomeScreen( + uiState = + HomeUiState( + isLoading = false, + userType = UserType.SOCIAL, + feeds = emptyList(), + selectedTab = HomeTab.MY_FEED, + ), onIntent = {}, ) } diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt index afe3bf30..cdc74f4d 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt @@ -8,6 +8,7 @@ import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel import com.sseotdabwa.buyornot.domain.model.Feed +import com.sseotdabwa.buyornot.domain.model.FeedCategory import com.sseotdabwa.buyornot.domain.model.FeedStatus import com.sseotdabwa.buyornot.domain.model.UserType import com.sseotdabwa.buyornot.domain.model.VoteChoice @@ -124,6 +125,13 @@ class HomeViewModel @Inject constructor( is HomeIntent.LoadFeeds -> loadFeeds() is HomeIntent.LoadNextPage -> handleNextPage() is HomeIntent.Refresh -> handleRefresh() + is HomeIntent.OnCategoryToggled -> handleCategoryToggled(intent.category) + is HomeIntent.OnAllCategorySelected -> { + updateState { it.copy(selectedCategories = emptySet()) } + loadFeeds() + } + is HomeIntent.ShowSortSheet -> updateState { it.copy(showSortSheet = true) } + is HomeIntent.DismissSortSheet -> updateState { it.copy(showSortSheet = false) } } } @@ -133,9 +141,12 @@ class HomeViewModel @Inject constructor( updateState { it.copy( selectedTab = tab, + selectedCategories = emptySet(), + selectedFilter = FilterChip.ALL, isLoading = true, hasError = false, feeds = emptyList(), + allFeeds = emptyList(), hasNextPage = false, nextCursor = null, isNextPageLoading = false, @@ -160,6 +171,19 @@ class HomeViewModel @Inject constructor( updateState { it.copy(isBannerVisible = false) } } + private fun handleCategoryToggled(category: FeedCategory) { + updateState { state -> + val updated = + if (category in state.selectedCategories) { + state.selectedCategories - category + } else { + state.selectedCategories + category + } + state.copy(selectedCategories = updated) + } + loadFeeds() + } + private fun handleNextPage() { if (currentState.isNextPageLoading || !currentState.hasNextPage) return @@ -168,12 +192,15 @@ class HomeViewModel @Inject constructor( val requestedTab = currentState.selectedTab val requestedFilter = currentState.selectedFilter + val requestedCategories = currentState.selectedCategories + val requestedCategory = requestedCategories.map { it.name }.takeIf { it.isNotEmpty() } runCatchingCancellable { when (requestedTab) { HomeTab.FEED -> feedRepository.getFeedList( cursor = currentState.nextCursor, feedStatus = requestedFilter.toFeedStatus(), + category = requestedCategory, ) HomeTab.MY_FEED -> feedRepository.getMyFeeds( @@ -193,9 +220,12 @@ class HomeViewModel @Inject constructor( feed.toFeedItem(isOwner) } + val newAllFeeds = currentState.allFeeds + newItems + updateState { it.copy( - feeds = it.feeds + newItems, + allFeeds = newAllFeeds, + feeds = applyCategories(newAllFeeds, it.selectedCategories), isNextPageLoading = false, hasNextPage = feedList.hasNext, nextCursor = feedList.nextCursor, @@ -215,21 +245,18 @@ class HomeViewModel @Inject constructor( val targetFeed = uiState.value.feeds.find { it.id == feedId } ?: return when { - targetFeed.isOwner -> { - sendSideEffect( - HomeSideEffect.ShowSnackbar( - message = "자신의 글에는 투표할 수 없습니다.", - icon = null, - ), - ) - return - } - targetFeed.isVoteEnded -> return + targetFeed.isOwner || targetFeed.isVoteEnded -> return targetFeed.userVotedOptionIndex != null -> return } // 1. 낙관적 업데이트 (Optimistic Update) - updateState { it.copy(feeds = optimisticVoteUpdate(it.feeds, feedId, optionIndex)) } + updateState { state -> + val newAllFeeds = optimisticVoteUpdate(state.allFeeds, feedId, optionIndex) + state.copy( + allFeeds = newAllFeeds, + feeds = applyCategories(newAllFeeds, state.selectedCategories), + ) + } viewModelScope.launch { val choice = if (optionIndex == 0) VoteChoice.YES else VoteChoice.NO @@ -241,32 +268,36 @@ class HomeViewModel @Inject constructor( } }.onSuccess { voteResult -> // 2. 최종 업데이트: 서버 응답으로 확정 - updateState { - it.copy( - feeds = - it.feeds.map { feed -> - if (feed.id == feedId) { - feed.copy( - userVotedOptionIndex = optionIndex, - buyVoteCount = voteResult.yesCount, - maybeVoteCount = voteResult.noCount, - totalVoteCount = voteResult.totalCount, - ) - } else { - feed - } - }, + updateState { state -> + val newAllFeeds = + state.allFeeds.map { feed -> + if (feed.id == feedId) { + feed.copy( + userVotedOptionIndex = optionIndex, + buyVoteCount = voteResult.yesCount, + maybeVoteCount = voteResult.noCount, + totalVoteCount = voteResult.totalCount, + ) + } else { + feed + } + } + state.copy( + allFeeds = newAllFeeds, + feeds = applyCategories(newAllFeeds, state.selectedCategories), ) } }.onFailure { e -> Log.e("HomeViewModel", "Failed to vote feed: $feedId", e) // 3. 롤백 (Rollback): 해당 피드만 원복, 나머지 동시 변경사항 보존 - updateState { - it.copy( - feeds = - it.feeds.map { feed -> - if (feed.id == feedId) targetFeed else feed - }, + updateState { state -> + val newAllFeeds = + state.allFeeds.map { feed -> + if (feed.id == feedId) targetFeed else feed + } + state.copy( + allFeeds = newAllFeeds, + feeds = applyCategories(newAllFeeds, state.selectedCategories), ) } @@ -307,7 +338,12 @@ class HomeViewModel @Inject constructor( runCatchingCancellable { feedRepository.deleteFeed(feedId.toLong()) }.onSuccess { - updateState { it.copy(feeds = it.feeds.filter { feed -> feed.id != feedId }) } + updateState { + it.copy( + allFeeds = it.allFeeds.filter { feed -> feed.id != feedId }, + feeds = it.feeds.filter { feed -> feed.id != feedId }, + ) + } sendSideEffect( HomeSideEffect.ShowSnackbar( message = "삭제가 완료되었습니다.", @@ -351,7 +387,12 @@ class HomeViewModel @Inject constructor( icon = null, ), ) - updateState { it.copy(feeds = it.feeds.filter { feed -> feed.authorUserId != userId }) } + updateState { + it.copy( + allFeeds = it.allFeeds.filter { feed -> feed.authorUserId != userId }, + feeds = it.feeds.filter { feed -> feed.authorUserId != userId }, + ) + } }.onFailure { e -> Log.e("HomeViewModel", "Failed to block user: $userId", e) sendSideEffect( @@ -405,7 +446,14 @@ class HomeViewModel @Inject constructor( ) { viewModelScope.launch { if (clearFeeds) { - updateState { it.copy(isLoading = true, hasError = false, feeds = emptyList()) } + updateState { + it.copy( + isLoading = true, + hasError = false, + feeds = emptyList(), + allFeeds = emptyList(), + ) + } } else { updateState { it.copy(hasError = false) } } @@ -418,19 +466,22 @@ class HomeViewModel @Inject constructor( val currentTab = tab ?: uiState.value.selectedTab val feedStatus = uiState.value.selectedFilter.toFeedStatus() + val selectedCategories = uiState.value.selectedCategories + val category = selectedCategories.map { it.name }.takeIf { it.isNotEmpty() } when (currentTab) { - HomeTab.FEED -> feedRepository.getFeedList(feedStatus = feedStatus) + HomeTab.FEED -> feedRepository.getFeedList(feedStatus = feedStatus, category = category) HomeTab.MY_FEED -> feedRepository.getMyFeeds(feedStatus = feedStatus) } }.onSuccess { feedList -> - val feeds = + val newFeeds = feedList.feeds.map { feed -> val isOwner = currentUserId != null && feed.author.userId == currentUserId feed.toFeedItem(isOwner) } updateState { it.copy( - feeds = feeds, + allFeeds = newFeeds, + feeds = applyCategories(newFeeds, it.selectedCategories), isLoading = false, hasError = false, hasNextPage = feedList.hasNext, @@ -456,20 +507,23 @@ class HomeViewModel @Inject constructor( val currentTab = currentState.selectedTab val feedStatus = currentState.selectedFilter.toFeedStatus() + val selectedCategories = currentState.selectedCategories + val category = selectedCategories.map { it.name }.takeIf { it.isNotEmpty() } runCatchingCancellable { when (currentTab) { - HomeTab.FEED -> feedRepository.getFeedList(feedStatus = feedStatus) + HomeTab.FEED -> feedRepository.getFeedList(feedStatus = feedStatus, category = category) HomeTab.MY_FEED -> feedRepository.getMyFeeds(feedStatus = feedStatus) } }.onSuccess { feedList -> - val feeds = + val refreshedFeeds = feedList.feeds.map { feed -> val isOwner = currentUserId != null && feed.author.userId == currentUserId feed.toFeedItem(isOwner) } updateState { it.copy( - feeds = feeds, + allFeeds = refreshedFeeds, + feeds = applyCategories(refreshedFeeds, it.selectedCategories), isRefreshing = false, hasNextPage = feedList.hasNext, nextCursor = feedList.nextCursor, @@ -482,6 +536,22 @@ class HomeViewModel @Inject constructor( } } + /** + * allFeeds에 카테고리 필터를 로컬 적용한다. + * categories가 비어있으면 전체 반환 (전체 = 아무것도 선택 안 됨). + */ + private fun applyCategories( + feeds: List, + categories: Set, + ): List = + if (categories.isEmpty()) { + feeds + } else { + feeds.filter { feed -> + categories.any { it.displayName == feed.category } + } + } + /** * FilterChip을 API feedStatus 파라미터로 변환 */ @@ -496,13 +566,9 @@ class HomeViewModel @Inject constructor( * Domain Feed를 UI FeedItem으로 변환 */ private fun Feed.toFeedItem(isOwner: Boolean): FeedItem { - val aspectRatio = - if (imageWidth == imageHeight) { - ImageAspectRatio.SQUARE - } else if (imageHeight > imageWidth) { - ImageAspectRatio.PORTRAIT - } else { - ImageAspectRatio.SQUARE + val aspectRatios = + images.map { image -> + if (image.imageHeight > image.imageWidth) ImageAspectRatio.PORTRAIT else ImageAspectRatio.SQUARE } return FeedItem( @@ -511,10 +577,11 @@ class HomeViewModel @Inject constructor( nickname = author.nickname, category = category.displayName, createdAt = TimeUtils.formatRelativeTime(createdAt), + title = title, content = content, - productImageUrl = viewUrl, + productImageUrls = viewUrls, price = price, - imageAspectRatio = aspectRatio, + imageAspectRatios = aspectRatios, isVoteEnded = feedStatus == FeedStatus.CLOSED, userVotedOptionIndex = when (myVoteChoice) { @@ -527,6 +594,7 @@ class HomeViewModel @Inject constructor( totalVoteCount = totalCount, isOwner = isOwner, authorUserId = author.userId, + productLink = productLink, ) } } diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/components/SettingItem.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/components/SettingItem.kt index 1e8287f8..7748d6da 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/components/SettingItem.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/components/SettingItem.kt @@ -13,7 +13,7 @@ import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme @Composable internal fun SettingItem( title: String, - textColor: Color = BuyOrNotTheme.colors.gray900, + textColor: Color = BuyOrNotTheme.colors.gray950, onClick: () -> Unit, ) { Surface( diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/navigation/MyPageNavigation.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/navigation/MyPageNavigation.kt index 1f95d5b5..c395bbb9 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/navigation/MyPageNavigation.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/navigation/MyPageNavigation.kt @@ -8,46 +8,49 @@ import androidx.navigation.navigation import com.sseotdabwa.buyornot.core.ui.webview.navigateToFeedBack import com.sseotdabwa.buyornot.core.ui.webview.navigateToPrivacyPolicy import com.sseotdabwa.buyornot.core.ui.webview.navigateToTerms -import com.sseotdabwa.buyornot.feature.mypage.ui.AccountSettingRoute -import com.sseotdabwa.buyornot.feature.mypage.ui.BlockedAccountsRoute -import com.sseotdabwa.buyornot.feature.mypage.ui.MyPageRoute -import com.sseotdabwa.buyornot.feature.mypage.ui.PolicyRoute -import com.sseotdabwa.buyornot.feature.mypage.ui.WithdrawalRoute - -sealed class MyPageScreens( - val route: String, -) { - object Graph : MyPageScreens("mypage_graph") +import kotlinx.serialization.Serializable +import com.sseotdabwa.buyornot.feature.mypage.ui.AccountSettingRoute as AccountSettingScreen +import com.sseotdabwa.buyornot.feature.mypage.ui.BlockedAccountsRoute as BlockedAccountsScreen +import com.sseotdabwa.buyornot.feature.mypage.ui.MyPageRoute as MyPageScreen +import com.sseotdabwa.buyornot.feature.mypage.ui.PolicyRoute as PolicyScreen +import com.sseotdabwa.buyornot.feature.mypage.ui.WithdrawalRoute as WithdrawalScreen - object Main : MyPageScreens("mypage_main") +@Serializable +data object MyPageGraph - object AccountSetting : MyPageScreens("account_setting") +@Serializable +data object MyPageMainRoute - object Policy : MyPageScreens("policy") +@Serializable +data object AccountSettingRoute - object Withdrawal : MyPageScreens("withdrawal") +@Serializable +data object PolicyRoute - object BlockedAccounts : MyPageScreens("blocked_accounts") -} +@Serializable +data object WithdrawalRoute + +@Serializable +data object BlockedAccountsRoute fun NavController.navigateToMyPage() { - this.navigate(MyPageScreens.Graph.route) + navigate(MyPageGraph) } fun NavController.navigateToAccountSetting() { - this.navigate(MyPageScreens.AccountSetting.route) + navigate(AccountSettingRoute) } fun NavController.navigateToPolicy() { - this.navigate(MyPageScreens.Policy.route) + navigate(PolicyRoute) } fun NavController.navigateToWithdrawal() { - this.navigate(MyPageScreens.Withdrawal.route) + navigate(WithdrawalRoute) } fun NavController.navigateToBlockedAccounts() { - this.navigate(MyPageScreens.BlockedAccounts.route) + navigate(BlockedAccountsRoute) } fun NavGraphBuilder.myPageGraph( @@ -55,12 +58,9 @@ fun NavGraphBuilder.myPageGraph( versionName: String, onNavigateToLogin: () -> Unit, ) { - navigation( - startDestination = MyPageScreens.Main.route, - route = MyPageScreens.Graph.route, - ) { - composable(MyPageScreens.Main.route) { - MyPageRoute( + navigation(startDestination = MyPageMainRoute) { + composable { + MyPageScreen( versionName = versionName, onBackClick = navController::popBackStack, onAccountSettingClick = navController::navigateToAccountSetting, @@ -70,31 +70,31 @@ fun NavGraphBuilder.myPageGraph( ) } - composable(MyPageScreens.AccountSetting.route) { - AccountSettingRoute( + composable { + AccountSettingScreen( onBackClick = navController::popBackStack, onNavigateToLogin = onNavigateToLogin, onNavigateToWithdrawal = navController::navigateToWithdrawal, ) } - composable(MyPageScreens.Policy.route) { - PolicyRoute( + composable { + PolicyScreen( onBackClick = navController::popBackStack, onNavigateToTerms = navController::navigateToTerms, onNavigateToPrivacyPolicy = navController::navigateToPrivacyPolicy, ) } - composable(MyPageScreens.Withdrawal.route) { - WithdrawalRoute( + composable { + WithdrawalScreen( onBackClick = navController::popBackStack, onNavigateToLogin = onNavigateToLogin, ) } - composable(MyPageScreens.BlockedAccounts.route) { - BlockedAccountsRoute( + composable { + BlockedAccountsScreen( onBackClick = navController::popBackStack, ) } diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/AccountSettingScreen.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/AccountSettingScreen.kt index e61d323f..6aed0b27 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/AccountSettingScreen.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/AccountSettingScreen.kt @@ -140,7 +140,7 @@ private fun EmailItem(email: String) { Text( text = "이메일", style = BuyOrNotTheme.typography.paragraphP1Medium, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Text( diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/BlockedAccountsScreen.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/BlockedAccountsScreen.kt index 5d097a8e..7ed14edb 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/BlockedAccountsScreen.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/BlockedAccountsScreen.kt @@ -172,7 +172,7 @@ private fun BlockedUser( Text( text = nickname, style = BuyOrNotTheme.typography.paragraphP2Medium, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) } diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/MyPageScreen.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/MyPageScreen.kt index 64ec9cb8..a1907e38 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/MyPageScreen.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/MyPageScreen.kt @@ -138,7 +138,7 @@ fun MyPageScreen( Text( text = uiState.userProfile?.nickname ?: "...", style = BuyOrNotTheme.typography.subTitleS1SemiBold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) } diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/WithdrawalScreen.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/WithdrawalScreen.kt index 643496ae..ea1591ba 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/WithdrawalScreen.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/ui/WithdrawalScreen.kt @@ -105,7 +105,7 @@ fun WithdrawalScreen( Text( text = "${uiState.userProfile?.nickname ?: "..."}님,\n살까말까를 떠나시나요?", style = BuyOrNotTheme.typography.headingH3Bold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) Spacer(modifier = Modifier.height(20.dp)) diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt index 8a76c2ff..479bc466 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt @@ -3,48 +3,49 @@ package com.sseotdabwa.buyornot.feature.notification.navigation import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.NavOptions -import androidx.navigation.NavType import androidx.navigation.compose.composable -import androidx.navigation.navArgument -import com.sseotdabwa.buyornot.feature.notification.ui.NotificationDetailRoute -import com.sseotdabwa.buyornot.feature.notification.ui.NotificationRoute +import kotlinx.serialization.Serializable +import com.sseotdabwa.buyornot.feature.notification.ui.NotificationDetailRoute as NotificationDetailScreen +import com.sseotdabwa.buyornot.feature.notification.ui.NotificationRoute as NotificationScreen -const val NOTIFICATION_ROUTE = "notification" -const val NOTIFICATION_DETAIL_ROUTE = "notification_detail" +@Serializable +data object NotificationRoute + +@Serializable +data class NotificationDetailRoute( + val notificationId: Long, + val feedId: Long, +) fun NavGraphBuilder.notificationGraph( onBackClick: () -> Unit, onNotificationClick: (Long, Long) -> Unit, + onLinkClick: (url: String) -> Unit = {}, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { - composable(route = NOTIFICATION_ROUTE) { - NotificationRoute( + composable { + NotificationScreen( onBackClick = onBackClick, onNotificationClick = onNotificationClick, ) } - composable( - route = "$NOTIFICATION_DETAIL_ROUTE/{notificationId}/{feedId}", - arguments = - listOf( - navArgument("notificationId") { type = NavType.LongType }, - navArgument("feedId") { type = NavType.LongType }, - ), - ) { - NotificationDetailRoute( + composable { + NotificationDetailScreen( onBackClick = onBackClick, + onLinkClick = onLinkClick, + onImageClick = onImageClick, ) } } fun NavHostController.navigateToNotification(navOptions: NavOptions? = null) { - this.navigate(NOTIFICATION_ROUTE, navOptions) + navigate(NotificationRoute, navOptions) } -// 상세 화면으로 이동하는 함수 fun NavHostController.navigateToNotificationDetail( notificationId: Long, feedId: Long, ) { - this.navigate("$NOTIFICATION_DETAIL_ROUTE/$notificationId/$feedId") + navigate(NotificationDetailRoute(notificationId = notificationId, feedId = feedId)) } diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt index 11b032cf..5333239e 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt @@ -12,7 +12,9 @@ import androidx.compose.material3.SnackbarHostState 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -32,6 +34,7 @@ import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.domain.model.Author import com.sseotdabwa.buyornot.domain.model.Feed import com.sseotdabwa.buyornot.domain.model.FeedCategory +import com.sseotdabwa.buyornot.domain.model.FeedImage import com.sseotdabwa.buyornot.domain.model.FeedStatus import com.sseotdabwa.buyornot.domain.model.VoteChoice @@ -47,6 +50,8 @@ import com.sseotdabwa.buyornot.domain.model.VoteChoice @Composable fun NotificationDetailRoute( onBackClick: () -> Unit, + onLinkClick: (url: String) -> Unit = {}, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, viewModel: NotificationDetailViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -72,6 +77,8 @@ fun NotificationDetailRoute( uiState = uiState, snackbarHostState = snackbarHostState, onBackClick = onBackClick, + onLinkClick = onLinkClick, + onImageClick = onImageClick, onIntent = viewModel::handleIntent, ) } @@ -81,6 +88,8 @@ fun NotificationDetailScreen( uiState: NotificationDetailUiState, onBackClick: () -> Unit, onIntent: (NotificationDetailIntent) -> Unit, + onLinkClick: (url: String) -> Unit = {}, + onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { if (uiState.showBlockDialog) { @@ -125,7 +134,7 @@ fun NotificationDetailScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = BuyOrNotTheme.colors.gray900) + CircularProgressIndicator(color = BuyOrNotTheme.colors.gray950) } } @@ -138,6 +147,7 @@ fun NotificationDetailScreen( uiState.feed != null -> { val feed = uiState.feed + var showLinkTooltip by remember { mutableStateOf(true) } Column( modifier = @@ -146,19 +156,18 @@ fun NotificationDetailScreen( .verticalScroll(rememberScrollState()), ) { FeedCard( - modifier = Modifier.padding(20.dp), + modifier = Modifier.padding(vertical = 26.dp), profileImageUrl = feed.author.profileImage ?: "", nickname = feed.author.nickname, category = feed.category.displayName, createdAt = TimeUtils.formatRelativeTime(feed.createdAt), + title = feed.title, content = feed.content, - productImageUrl = feed.viewUrl, + productImageUrls = feed.viewUrls, price = feed.price, - imageAspectRatio = - if (feed.imageWidth > 0 && feed.imageHeight > 0) { - if (feed.imageHeight > feed.imageWidth) ImageAspectRatio.PORTRAIT else ImageAspectRatio.SQUARE - } else { - ImageAspectRatio.SQUARE + imageAspectRatios = + feed.images.map { image -> + if (image.imageHeight > image.imageWidth) ImageAspectRatio.PORTRAIT else ImageAspectRatio.SQUARE }, isVoteEnded = feed.feedStatus == FeedStatus.CLOSED, userVotedOptionIndex = @@ -177,6 +186,10 @@ fun NotificationDetailScreen( onReportClick = { onIntent(NotificationDetailIntent.OnReportClicked) }, onBlockClick = { onIntent(NotificationDetailIntent.ShowBlockDialog) }, showMoreButton = !uiState.isGuest, + productLink = feed.productLink, + onLinkClick = onLinkClick, + showProductLinkTooltip = showLinkTooltip && feed.productLink != null, + onImageClick = onImageClick, ) } } @@ -196,6 +209,7 @@ private fun NotificationDetailScreenPreview() { feed = Feed( feedId = 1L, + title = "", content = "이거 어때요? 투표 결과가 궁금해요!", price = "35,000", category = FeedCategory.BOOK, @@ -203,10 +217,15 @@ private fun NotificationDetailScreenPreview() { noCount = 20, totalCount = 100, feedStatus = FeedStatus.CLOSED, - s3ObjectKey = "", - viewUrl = "https://picsum.photos/800/800", - imageWidth = 800, - imageHeight = 800, + images = + listOf( + FeedImage( + s3ObjectKey = "", + imageUrl = "https://picsum.photos/800/800", + imageWidth = 800, + imageHeight = 800, + ), + ), author = Author( userId = 1L, diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt index b1ba63d3..fea98bb6 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationItem.kt @@ -23,40 +23,18 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme -/** - * 알림 아이템의 상태를 정의 데이터 클래스 - * - * @param id: 알림의 고유 식별자 - * @param imageUrl: 알림의 이미지 URL - * @param label: 알림의 라벨 (예: "투표 종료") - * @param message: 알림의 메시지 (예: "78% '애매하긴 해!'") - * @param time: 알림이 생성된 시간 (예: "6시간 전") - * @param isRead: 알림이 읽었는지 여부 (안 읽음: false, 읽음: true) - */ -data class NotificationState( - val id: Long, - val imageUrl: String, - val label: String, - val message: String, - val time: String, - val isRead: Boolean, -) - -/** - * 알림 화면의 개별 아이템 컴포저블 - * - * @param state: 알림 아이템의 상태를 나타내는 NotificationState 객체 - * @param onClick: 아이템을 클릭했을 때의 콜백 액션 - * @param modifier: 컴포저블에 적용할 Modifier - */ @Composable fun NotificationItem( - state: NotificationState, + id: Long, + imageUrl: String, + label: String, + message: String, + time: String, + isRead: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { - // 읽음 상태에 따른 배경색 결정 - val backgroundColor = if (state.isRead) BuyOrNotTheme.colors.gray50 else BuyOrNotTheme.colors.gray0 + val backgroundColor = if (isRead) BuyOrNotTheme.colors.gray50 else BuyOrNotTheme.colors.gray0 Row( modifier = @@ -67,9 +45,8 @@ fun NotificationItem( .padding(20.dp), verticalAlignment = Alignment.CenterVertically, ) { - // 좌측 이미지 AsyncImage( - model = state.imageUrl, + model = imageUrl, contentDescription = null, modifier = Modifier @@ -80,7 +57,6 @@ fun NotificationItem( Spacer(modifier = Modifier.width(14.dp)) - // 우측 텍스트 영역 Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center, @@ -91,12 +67,12 @@ fun NotificationItem( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = state.label, + text = label, style = BuyOrNotTheme.typography.bodyB5Medium, color = BuyOrNotTheme.colors.gray600, ) Text( - text = state.time, + text = time, style = BuyOrNotTheme.typography.bodyB6Medium, color = BuyOrNotTheme.colors.gray600, ) @@ -105,9 +81,9 @@ fun NotificationItem( Spacer(modifier = Modifier.height(6.dp)) Text( - text = state.message, + text = message, style = BuyOrNotTheme.typography.subTitleS3SemiBold, - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ) } } @@ -118,15 +94,12 @@ fun NotificationItem( private fun UnreadNotiPreview() { BuyOrNotTheme { NotificationItem( - state = - NotificationState( - id = 1L, - imageUrl = "https://picsum.photos/200", - label = "투표 종료", - message = "90% '애매하긴 해!'", - time = "3일 전", - isRead = false, - ), + id = 1L, + imageUrl = "https://picsum.photos/200", + label = "투표 종료", + message = "90% '애매하긴 해!'", + time = "3일 전", + isRead = false, onClick = {}, ) } @@ -137,15 +110,12 @@ private fun UnreadNotiPreview() { private fun ReadNotiPreview() { BuyOrNotTheme { NotificationItem( - state = - NotificationState( - id = 2L, - imageUrl = "https://picsum.photos/200", - label = "투표 종료", - message = "56% '사! 가즈아!'", - time = "3일 전", - isRead = true, - ), + id = 2L, + imageUrl = "https://picsum.photos/200", + label = "투표 종료", + message = "56% '사! 가즈아!'", + time = "3일 전", + isRead = true, onClick = {}, ) } diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt index 1941e647..91b9a793 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationScreen.kt @@ -234,25 +234,20 @@ fun NotificationScreen( key = { it.id }, ) { notification -> NotificationItem( - state = - NotificationState( - id = notification.id, - imageUrl = notification.imageUrl, - label = notification.title, - message = notification.description, - time = notification.time, - isRead = notification.isRead, - ), + id = notification.id, + imageUrl = notification.imageUrl, + label = notification.title, + message = notification.description, + time = notification.time, + isRead = notification.isRead, onClick = { onIntent(NotificationIntent.OnNotificationClick(notification.id, notification.feedId)) }, ) - if (notification != uiState.notifications.last()) { - BuyOrNotDivider( - size = BuyOrNotDividerSize.Small, - ) - } + BuyOrNotDivider( + size = BuyOrNotDividerSize.Small, + ) } } @@ -263,12 +258,12 @@ fun NotificationScreen( modifier = Modifier .fillMaxWidth() - .padding(vertical = 20.dp), + .padding(vertical = 10.dp), contentAlignment = Alignment.Center, ) { Text( text = "30일 전 알림까지 보여줘요", - style = BuyOrNotTheme.typography.bodyB6Medium, + style = BuyOrNotTheme.typography.bodyB4Medium, color = BuyOrNotTheme.colors.gray400, ) } diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationViewModel.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationViewModel.kt index ff41fd5b..9246a8a7 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationViewModel.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationViewModel.kt @@ -134,7 +134,7 @@ class NotificationViewModel @Inject constructor( feedId = it.feedId, imageUrl = it.viewUrl, title = it.title, - description = it.body, + description = it.feedTitle, time = TimeUtils.formatRelativeTime(it.voteClosedAt), isRead = it.isRead, ) diff --git a/feature/upload/build.gradle.kts b/feature/upload/build.gradle.kts index 02e52908..ef861942 100644 --- a/feature/upload/build.gradle.kts +++ b/feature/upload/build.gradle.kts @@ -12,4 +12,7 @@ dependencies { implementation(projects.core.designsystem) implementation(projects.core.ui) implementation(libs.coil.compose) + + testImplementation(libs.junit) + testImplementation(libs.kotlin.test) } diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/navigation/UploadNavigation.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/navigation/UploadNavigation.kt index 2c8a97ab..97cd877a 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/navigation/UploadNavigation.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/navigation/UploadNavigation.kt @@ -4,19 +4,21 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable -import com.sseotdabwa.buyornot.feature.upload.ui.UploadScreen +import kotlinx.serialization.Serializable +import com.sseotdabwa.buyornot.feature.upload.ui.UploadRoute as UploadScreen -const val UPLOAD_ROUTE = "upload" +@Serializable +data object UploadRoute fun NavController.navigateToUpload(navOptions: NavOptions? = null) { - navigate(UPLOAD_ROUTE, navOptions) + navigate(UploadRoute, navOptions) } fun NavGraphBuilder.uploadScreen( onNavigateBack: () -> Unit = {}, onNavigateToHomeReview: () -> Unit = {}, ) { - composable(route = UPLOAD_ROUTE) { + composable { UploadScreen( onNavigateBack = onNavigateBack, onNavigateToHomeReview = onNavigateToHomeReview, diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt index 508fad7b..8a9eee49 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt @@ -7,17 +7,26 @@ import com.sseotdabwa.buyornot.domain.model.FeedCategory data class UploadUiState( val isLoading: Boolean = false, - val selectedImageUri: Uri? = null, + val selectedImageUris: List = emptyList(), val category: FeedCategory? = null, val price: String = "", val priceFieldValue: TextFieldValue = TextFieldValue(""), + val link: String = "", + val title: String = "", val content: String = "", val showCategorySheet: Boolean = false, val showExitDialog: Boolean = false, + val showPhotoPickerSheet: Boolean = false, val categories: List = FeedCategory.entries, ) { val hasInput: Boolean - get() = selectedImageUri != null || category != null || price.isNotEmpty() || content.isNotEmpty() + get() = + selectedImageUris.isNotEmpty() || + category != null || + price.isNotEmpty() || + link.isNotEmpty() || + title.isNotEmpty() || + content.isNotEmpty() } sealed interface UploadIntent { @@ -30,12 +39,24 @@ sealed interface UploadIntent { val textFieldValue: TextFieldValue, ) : UploadIntent + data class UpdateLink( + val link: String, + ) : UploadIntent + + data class UpdateTitle( + val title: String, + ) : UploadIntent + data class UpdateContent( val content: String, ) : UploadIntent - data class SelectImage( - val uri: Uri?, + data class AddImages( + val uris: List, + ) : UploadIntent + + data class RemoveImage( + val uri: Uri, ) : UploadIntent data class Submit( @@ -50,6 +71,10 @@ sealed interface UploadIntent { val isVisible: Boolean, ) : UploadIntent + data class UpdatePhotoPickerSheetVisibility( + val isVisible: Boolean, + ) : UploadIntent + data object NavigateBack : UploadIntent } diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt index a18eedf9..4848def5 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt @@ -1,8 +1,14 @@ package com.sseotdabwa.buyornot.feature.upload.ui +import android.Manifest +import android.content.ContentValues +import android.content.pm.PackageManager import android.net.Uri +import android.os.Build +import android.provider.MediaStore import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -16,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -34,9 +41,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf 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.draw.clip @@ -52,18 +60,20 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage +import com.sseotdabwa.buyornot.core.designsystem.components.ActionItem +import com.sseotdabwa.buyornot.core.designsystem.components.ActionSheet import com.sseotdabwa.buyornot.core.designsystem.components.BackTopBar import com.sseotdabwa.buyornot.core.designsystem.components.ButtonSize import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog @@ -77,7 +87,7 @@ import com.sseotdabwa.buyornot.core.ui.snackbar.LocalSnackbarState import java.text.DecimalFormat @Composable -fun UploadScreen( +fun UploadRoute( modifier: Modifier = Modifier, onNavigateBack: () -> Unit = {}, onNavigateToHomeReview: () -> Unit = {}, @@ -90,40 +100,154 @@ fun UploadScreen( LaunchedEffect(Unit) { viewModel.sideEffect.collect { sideEffect -> when (sideEffect) { - is UploadSideEffect.ShowSnackbar -> { - snackbarState.show(sideEffect.message) - } - + is UploadSideEffect.ShowSnackbar -> snackbarState.show(sideEffect.message) is UploadSideEffect.NavigateBack -> onNavigateBack() is UploadSideEffect.NavigateToHomeReview -> onNavigateToHomeReview() } } } - val decimalFormat = remember { DecimalFormat("#,###") } - val scrollState = rememberScrollState() + val keyboardController = LocalSoftwareKeyboardController.current + + var photoUri by remember { mutableStateOf(null) } + + val insertPhotoUri: () -> Uri? = { + val contentValues = + ContentValues().apply { + put( + MediaStore.Images.Media.DISPLAY_NAME, + "buyornot_${System.currentTimeMillis()}.jpg", + ) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/BuyOrNot") + } + } + context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues, + ) + } + + val cameraLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture(), + ) { success -> + if (success) { + photoUri?.let { viewModel.handleIntent(UploadIntent.AddImages(listOf(it))) } + } else { + photoUri?.let { context.contentResolver.delete(it, null, null) } + } + photoUri = null + keyboardController?.hide() + } + + val cameraPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + val uri = insertPhotoUri() + if (uri != null) { + photoUri = uri + cameraLauncher.launch(uri) + } + } else { + snackbarState.show("카메라 권한을 허용해 주세요.") + } + } val galleryLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - ) { uri: Uri? -> - viewModel.handleIntent(UploadIntent.SelectImage(uri)) + contract = ActivityResultContracts.PickMultipleVisualMedia(maxItems = 3), + ) { uris: List -> + if (uris.isNotEmpty()) viewModel.handleIntent(UploadIntent.AddImages(uris)) + keyboardController?.hide() } - val isSubmitEnabled by remember { - derivedStateOf { + UploadScreen( + modifier = modifier, + uiState = uiState, + onIntent = viewModel::handleIntent, + onPickImage = { + if (uiState.selectedImageUris.size < 3) { + viewModel.handleIntent(UploadIntent.UpdatePhotoPickerSheetVisibility(true)) + } + }, + onSubmit = { + keyboardController?.hide() + viewModel.handleIntent(UploadIntent.Submit(context)) + }, + ) + + if (uiState.showPhotoPickerSheet) { + ActionSheet( + actions = + listOf( + ActionItem( + icon = BuyOrNotIcons.Camera, + text = "카메라로 직접 찍기", + onClick = { + val hasCameraPermission = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCameraPermission) { + val uri = insertPhotoUri() + if (uri != null) { + photoUri = uri + cameraLauncher.launch(uri) + } + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + ), + ActionItem( + icon = BuyOrNotIcons.Gallery, + text = "앨범에서 사진 선택", + onClick = { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + ), + ), + onDismissRequest = { + viewModel.handleIntent(UploadIntent.UpdatePhotoPickerSheetVisibility(false)) + }, + ) + } +} + +@Composable +fun UploadScreen( + uiState: UploadUiState, + onIntent: (UploadIntent) -> Unit, + onPickImage: () -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, +) { + val decimalFormat = remember { DecimalFormat("#,###") } + val scrollState = rememberScrollState() + + val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 + + val isSubmitEnabled = + remember(uiState) { uiState.category != null && uiState.price.isNotEmpty() && - uiState.selectedImageUri != null && + uiState.title.isNotEmpty() && + uiState.selectedImageUris.isNotEmpty() && !uiState.isLoading } - } BackHandler { if (uiState.hasInput) { - if (!uiState.showExitDialog) viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(true)) + if (!uiState.showExitDialog) onIntent(UploadIntent.UpdateExitDialogVisibility(true)) } else { - viewModel.handleIntent(UploadIntent.NavigateBack) + onIntent(UploadIntent.NavigateBack) } } @@ -137,9 +261,9 @@ fun UploadScreen( ) { BackTopBar { if (uiState.hasInput) { - viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(true)) + onIntent(UploadIntent.UpdateExitDialogVisibility(true)) } else { - viewModel.handleIntent(UploadIntent.NavigateBack) + onIntent(UploadIntent.NavigateBack) } } @@ -152,7 +276,18 @@ fun UploadScreen( ) { CategorySelectorRow( selectedCategory = uiState.category?.displayName, - onCategoryClick = { viewModel.handleIntent(UploadIntent.UpdateCategorySheetVisibility(true)) }, + onCategoryClick = { onIntent(UploadIntent.UpdateCategorySheetVisibility(true)) }, + ) + + HorizontalDivider( + thickness = 2.dp, + color = BuyOrNotTheme.colors.gray100, + ) + + LinkInputField( + modifier = Modifier.padding(vertical = 18.dp), + link = uiState.link, + onLinkChange = { onIntent(UploadIntent.UpdateLink(it)) }, ) HorizontalDivider( @@ -166,7 +301,7 @@ fun UploadScreen( priceRaw = uiState.price, decimalFormat = decimalFormat, onPriceChange = { digits, textFieldValue -> - viewModel.handleIntent(UploadIntent.UpdatePrice(digits, textFieldValue)) + onIntent(UploadIntent.UpdatePrice(digits, textFieldValue)) }, ) @@ -178,16 +313,18 @@ fun UploadScreen( Spacer(modifier = Modifier.height(20.dp)) ContentInputField( + title = uiState.title, + onTitleChange = { onIntent(UploadIntent.UpdateTitle(it)) }, content = uiState.content, - onContentChange = { viewModel.handleIntent(UploadIntent.UpdateContent(it)) }, + onContentChange = { onIntent(UploadIntent.UpdateContent(it)) }, ) Spacer(modifier = Modifier.height(10.dp)) ImagePickerRow( - selectedImageUri = uiState.selectedImageUri, - onPickImage = { galleryLauncher.launch("image/*") }, - onRemoveImage = { viewModel.handleIntent(UploadIntent.SelectImage(null)) }, + selectedImageUris = uiState.selectedImageUris, + onPickImage = onPickImage, + onRemoveImage = { uri -> onIntent(UploadIntent.RemoveImage(uri)) }, ) } @@ -195,22 +332,43 @@ fun UploadScreen( modifier = Modifier .fillMaxWidth() - .padding(20.dp), - horizontalArrangement = Arrangement.End, + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = if (isImeVisible) Arrangement.SpaceBetween else Arrangement.End, verticalAlignment = Alignment.CenterVertically, ) { - if (isSubmitEnabled) { - ToolTip() - Spacer(modifier = Modifier.width(6.dp)) + if (isImeVisible) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = BuyOrNotIcons.Camera.asImageVector(), + contentDescription = null, + modifier = + Modifier + .size(18.dp) + .clickable(onClick = onPickImage), + tint = BuyOrNotTheme.colors.gray800, + ) + Text( + text = "${uiState.selectedImageUris.size}/3", + style = BuyOrNotTheme.typography.subTitleS5SemiBold, + color = BuyOrNotTheme.colors.gray800, + ) + } + } else { + if (isSubmitEnabled) { + ToolTip() + Spacer(modifier = Modifier.width(6.dp)) + } } CapsuleButton( text = "투표 게시!", enabled = isSubmitEnabled, size = ButtonSize.Small, - ) { - viewModel.handleIntent(UploadIntent.Submit(context)) - } + onClick = onSubmit, + ) } } @@ -222,28 +380,28 @@ fun UploadScreen( onOptionClick = { displayName -> val category = uiState.categories.find { it.displayName == displayName } if (category != null) { - viewModel.handleIntent(UploadIntent.UpdateCategory(category)) + onIntent(UploadIntent.UpdateCategory(category)) } }, onDismissRequest = { - viewModel.handleIntent(UploadIntent.UpdateCategorySheetVisibility(false)) + onIntent(UploadIntent.UpdateCategorySheetVisibility(false)) }, ) } if (uiState.showExitDialog) { BuyOrNotAlertDialog( - onDismissRequest = { viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(false)) }, + onDismissRequest = { onIntent(UploadIntent.UpdateExitDialogVisibility(false)) }, title = "다음에 등록할까요?", subText = "지금까지 쓴 내용은 저장되지 않아요.", confirmText = "유지하기", dismissText = "나가기", onConfirm = { - viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(false)) + onIntent(UploadIntent.UpdateExitDialogVisibility(false)) }, onDismiss = { - viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(false)) - viewModel.handleIntent(UploadIntent.NavigateBack) + onIntent(UploadIntent.UpdateExitDialogVisibility(false)) + onIntent(UploadIntent.NavigateBack) }, ) } @@ -279,6 +437,59 @@ private fun CategorySelectorRow( } } +@Composable +private fun LinkInputField( + link: String, + onLinkChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = BuyOrNotIcons.Link.asImageVector(), + contentDescription = "Link", + modifier = Modifier.size(18.dp), + tint = BuyOrNotTheme.colors.gray600, + ) + Spacer(modifier = Modifier.width(6.dp)) + + BasicTextField( + value = link, + onValueChange = onLinkChange, + modifier = Modifier.fillMaxWidth(), + textStyle = + BuyOrNotTheme.typography.subTitleS3SemiBold.copy( + color = BuyOrNotTheme.colors.gray800, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + singleLine = true, + decorationBox = { innerTextField -> + Box( + contentAlignment = Alignment.CenterStart, + ) { + if (link.isEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "상품 링크 ", + style = BuyOrNotTheme.typography.subTitleS3SemiBold, + color = BuyOrNotTheme.colors.gray600, + ) + Text( + text = "(선택)", + style = BuyOrNotTheme.typography.subTitleS5SemiBold, + color = BuyOrNotTheme.colors.gray600, + ) + } + } + innerTextField() + } + }, + ) + } +} + @Composable private fun PriceInputField( modifier: Modifier = Modifier, @@ -361,12 +572,37 @@ private fun PriceInputField( @Composable private fun ContentInputField( + title: String, + onTitleChange: (String) -> Unit, content: String, onContentChange: (String) -> Unit, ) { Column( modifier = Modifier.fillMaxWidth(), ) { + BasicTextField( + value = title, + onValueChange = { if (it.length <= 40) onTitleChange(it) }, + modifier = Modifier.fillMaxWidth(), + textStyle = + BuyOrNotTheme.typography.titleT2Bold.copy( + color = BuyOrNotTheme.colors.gray950, + ), + singleLine = true, + decorationBox = { innerTextField -> + if (title.isEmpty()) { + Text( + text = "제목", + style = BuyOrNotTheme.typography.titleT2Bold, + color = BuyOrNotTheme.colors.gray600, + ) + } + innerTextField() + }, + ) + + Spacer(modifier = Modifier.height(12.dp)) + BasicTextField( value = content, onValueChange = { if (it.length <= 100) onContentChange(it) }, @@ -376,7 +612,7 @@ private fun ContentInputField( .heightIn(min = 84.dp), textStyle = BuyOrNotTheme.typography.paragraphP2Medium.copy( - color = BuyOrNotTheme.colors.gray900, + color = BuyOrNotTheme.colors.gray950, ), decorationBox = { innerTextField -> if (content.isEmpty()) { @@ -403,22 +639,23 @@ private fun ContentInputField( @Composable private fun ImagePickerRow( - selectedImageUri: Uri?, + selectedImageUris: List, onPickImage: () -> Unit, - onRemoveImage: () -> Unit, + onRemoveImage: (Uri) -> Unit, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { CameraButton( - selectedCount = if (selectedImageUri != null) 1 else 0, + selectedCount = selectedImageUris.size, + enabled = selectedImageUris.size < 3, onClick = onPickImage, ) - selectedImageUri?.let { + selectedImageUris.forEach { uri -> SelectedImagePreview( - imageUri = it, - onRemove = onRemoveImage, + imageUri = uri, + onRemove = { onRemoveImage(uri) }, ) } } @@ -427,6 +664,7 @@ private fun ImagePickerRow( @Composable private fun CameraButton( selectedCount: Int, + enabled: Boolean, onClick: () -> Unit, ) { Surface( @@ -434,6 +672,7 @@ private fun CameraButton( shape = RoundedCornerShape(12.dp), color = BuyOrNotTheme.colors.gray100, onClick = onClick, + enabled = enabled, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -450,28 +689,9 @@ private fun CameraButton( tint = BuyOrNotTheme.colors.gray600, ) Text( - text = - buildAnnotatedString { - withStyle( - style = - SpanStyle( - color = - if (selectedCount > 0) { - BuyOrNotTheme.colors.gray800 - } else { - BuyOrNotTheme.colors.gray600 - }, - ), - ) { - append("$selectedCount") - } - withStyle( - style = SpanStyle(color = BuyOrNotTheme.colors.gray600), - ) { - append("/1") - } - }, + text = "$selectedCount/3", style = BuyOrNotTheme.typography.subTitleS5SemiBold, + color = BuyOrNotTheme.colors.gray600, ) } } @@ -568,11 +788,9 @@ fun Modifier.customShadow( offsetX: Dp = 40.dp, offsetY: Dp = 4.dp, ) = this.drawBehind { - // 1. 전달받은 Shape로부터 현재 사이즈에 맞는 Outline을 생성합니다. val outline = shape.createOutline(size, layoutDirection, this) val path = Path().apply { - // Outline을 Path 형태로 변환합니다. addOutline(outline) } @@ -580,7 +798,6 @@ fun Modifier.customShadow( val paint = Paint().asFrameworkPaint().apply { this.color = android.graphics.Color.TRANSPARENT - // 설정된 Offset과 Blur(Spread)를 적용합니다. setShadowLayer( blur.toPx(), offsetX.toPx(), @@ -588,7 +805,6 @@ fun Modifier.customShadow( color.toArgb(), ) } - // Compose Path를 Native Path로 변환하여 그림자를 그립니다. canvas.nativeCanvas.drawPath(path.asAndroidPath(), paint) } } @@ -597,6 +813,11 @@ fun Modifier.customShadow( @Composable private fun UploadScreenPreview() { BuyOrNotTheme { - UploadScreen() + UploadScreen( + uiState = UploadUiState(), + onIntent = {}, + onPickImage = {}, + onSubmit = {}, + ) } } diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt index 585d9100..33d30979 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt @@ -7,7 +7,9 @@ import android.provider.OpenableColumns import androidx.lifecycle.viewModelScope import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel +import com.sseotdabwa.buyornot.domain.model.FeedImage import com.sseotdabwa.buyornot.domain.repository.FeedRepository +import com.sseotdabwa.buyornot.feature.upload.util.LinkValidator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -17,6 +19,8 @@ class UploadViewModel @Inject constructor( private val feedRepository: FeedRepository, ) : BaseViewModel(UploadUiState()) { companion object { + private const val MAX_IMAGE_COUNT = 3 + private const val MAX_TITLE_LENGTH = 40 private const val MAX_CONTENT_LENGTH = 100 } @@ -30,12 +34,29 @@ class UploadViewModel @Inject constructor( updateState { it.copy(price = intent.digits, priceFieldValue = intent.textFieldValue) } + is UploadIntent.UpdateLink -> + updateState { it.copy(link = intent.link) } + is UploadIntent.UpdateTitle -> { + if (intent.title.length <= MAX_TITLE_LENGTH) { + updateState { it.copy(title = intent.title) } + } + } is UploadIntent.UpdateContent -> { if (intent.content.length <= MAX_CONTENT_LENGTH) { updateState { it.copy(content = intent.content) } } } - is UploadIntent.SelectImage -> updateState { it.copy(selectedImageUri = intent.uri) } + is UploadIntent.AddImages -> { + val remaining = MAX_IMAGE_COUNT - currentState.selectedImageUris.size + val toAdd = intent.uris.take(remaining) + val hasOverflow = toAdd.size < intent.uris.size + updateState { it.copy(selectedImageUris = it.selectedImageUris + toAdd) } + if (hasOverflow) sendSideEffect(UploadSideEffect.ShowSnackbar("최대 ${MAX_IMAGE_COUNT}장까지 추가할 수 있어요")) + } + is UploadIntent.RemoveImage -> + updateState { + it.copy(selectedImageUris = it.selectedImageUris.filter { uri -> uri != intent.uri }) + } is UploadIntent.Submit -> submitFeed(intent.context) is UploadIntent.UpdateCategorySheetVisibility -> updateState { @@ -45,6 +66,10 @@ class UploadViewModel @Inject constructor( updateState { it.copy(showExitDialog = intent.isVisible) } + is UploadIntent.UpdatePhotoPickerSheetVisibility -> + updateState { + it.copy(showPhotoPickerSheet = intent.isVisible) + } UploadIntent.NavigateBack -> { updateState { it.copy(showExitDialog = false, showCategorySheet = false) @@ -57,7 +82,16 @@ class UploadViewModel @Inject constructor( private fun submitFeed(context: Context) { if (currentState.isLoading) return - val uri = currentState.selectedImageUri ?: return + val title = currentState.title.trim().takeIf { it.isNotBlank() } + val link = currentState.link.trim().takeIf { it.isNotBlank() } + + if (!LinkValidator.isValid(link.orEmpty())) { + sendSideEffect(UploadSideEffect.ShowSnackbar("링크 주소를 다시 확인해 주세요.")) + return + } + + val uris = currentState.selectedImageUris + if (uris.isEmpty()) return val category = currentState.category ?: return val price = currentState.price.toIntOrNull() ?: return val content = currentState.content @@ -65,25 +99,39 @@ class UploadViewModel @Inject constructor( viewModelScope.launch { updateState { it.copy(isLoading = true) } - runCatchingCancellable { - val (width, height) = getImageDimensions(context, uri) - - val contentType = context.contentResolver.getType(uri) ?: "image/jpeg" - val fileName = getFileName(context, uri) ?: "upload_image.jpg" - - val uploadInfo = feedRepository.getPresignedUrl(fileName, contentType) + val dimensionMap = uris.associateWith { getImageDimensions(context, it) } + if (dimensionMap.values.any { (w, h) -> w <= 0 || h <= 0 }) { + updateState { it.copy(isLoading = false) } + sendSideEffect(UploadSideEffect.ShowSnackbar("이미지를 읽을 수 없습니다.")) + return@launch + } - val inputStream = context.contentResolver.openInputStream(uri) - val bytes = inputStream?.use { it.readBytes() } ?: throw Exception("파일을 읽을 수 없습니다.") - feedRepository.uploadImage(uploadInfo.uploadUrl, bytes, contentType) + runCatchingCancellable { + val feedImages = + uris.map { uri -> + val (width, height) = dimensionMap.getValue(uri) + val contentType = context.contentResolver.getType(uri) ?: "image/jpeg" + val fileName = getFileName(context, uri) ?: "upload_image.jpg" + val uploadInfo = feedRepository.getPresignedUrl(fileName, contentType) + val bytes = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw Exception("파일을 읽을 수 없습니다.") + feedRepository.uploadImage(uploadInfo.uploadUrl, bytes, contentType) + FeedImage( + s3ObjectKey = uploadInfo.s3ObjectKey, + imageUrl = uploadInfo.viewUrl, + imageWidth = width, + imageHeight = height, + ) + } feedRepository.createFeed( category = category, price = price, content = content, - s3ObjectKey = uploadInfo.s3ObjectKey, - imageWidth = width, - imageHeight = height, + images = feedImages, + title = title, + link = link, ) }.onSuccess { updateState { it.copy(isLoading = false) } diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidator.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidator.kt new file mode 100644 index 00000000..8576cb25 --- /dev/null +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidator.kt @@ -0,0 +1,13 @@ +package com.sseotdabwa.buyornot.feature.upload.util + +object LinkValidator { + private val VALID_URL_REGEX = Regex("^https?://\\S+$") + private val HANGUL_REGEX = Regex("[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F]") + + fun isValid(url: String): Boolean { + if (url.isEmpty()) return true + if (!VALID_URL_REGEX.matches(url)) return false + if (HANGUL_REGEX.containsMatchIn(url)) return false + return true + } +} diff --git a/feature/upload/src/test/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidatorTest.kt b/feature/upload/src/test/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidatorTest.kt new file mode 100644 index 00000000..d3e14b5a --- /dev/null +++ b/feature/upload/src/test/java/com/sseotdabwa/buyornot/feature/upload/util/LinkValidatorTest.kt @@ -0,0 +1,77 @@ +package com.sseotdabwa.buyornot.feature.upload.util + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LinkValidatorTest { + @Test + fun `빈 문자열은 유효하다`() { + assertTrue(LinkValidator.isValid("")) + } + + @Test + fun `http로 시작하는 정상 URL은 유효하다`() { + assertTrue(LinkValidator.isValid("http://naver.com")) + } + + @Test + fun `https로 시작하는 정상 URL은 유효하다`() { + assertTrue(LinkValidator.isValid("https://naver.com")) + } + + @Test + fun `https로 시작하는 경로가 포함된 URL은 유효하다`() { + assertTrue(LinkValidator.isValid("https://www.naver.com/search?q=test")) + } + + @Test + fun `스킴이 없는 URL은 유효하지 않다`() { + assertFalse(LinkValidator.isValid("naver.com")) + } + + @Test + fun `www로만 시작하는 URL은 유효하지 않다`() { + assertFalse(LinkValidator.isValid("www.naver.com")) + } + + @Test + fun `ttps로 시작해 스킴 철자가 틀린 URL은 유효하지 않다`() { + assertFalse(LinkValidator.isValid("ttps://naver.com")) + } + + @Test + fun `http에 콜론이 없는 URL은 유효하지 않다`() { + assertFalse(LinkValidator.isValid("http//naver.com")) + } + + @Test + fun `https에 슬래시가 없는 URL은 유효하지 않다`() { + assertFalse(LinkValidator.isValid("https:naver.com")) + } + + @Test + fun `도메인에 한글이 포함된 URL은 유효하지 않다`() { + assertFalse(LinkValidator.isValid("https://네이버.com")) + } + + @Test + fun `경로에 한글이 포함된 URL은 유효하지 않다`() { + assertFalse(LinkValidator.isValid("https://naver.com/한글경로")) + } + + @Test + fun `URL 중간에 공백이 있으면 유효하지 않다`() { + assertFalse(LinkValidator.isValid("https://naver .com")) + } + + @Test + fun `URL 앞에 공백이 있으면 유효하지 않다`() { + assertFalse(LinkValidator.isValid(" https://naver.com")) + } + + @Test + fun `URL 뒤에 공백이 있으면 유효하지 않다`() { + assertFalse(LinkValidator.isValid("https://naver.com ")) + } +}