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 "))
+ }
+}