From 792c0445764ff5e2499dbe64261865623bfdda02 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 28 Apr 2026 19:10:54 +0900 Subject: [PATCH 01/24] =?UTF-8?q?feat/#99:=20=EC=B5=9C=EC=83=81=EB=8B=A8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20FilterChipRow=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=ED=88=AC=EB=AA=85=20=EC=B2=98=EB=A6=AC=EB=A1=9C=20=EB=B0=B0?= =?UTF-8?q?=EB=84=88=20=EA=B7=B8=EB=A6=BC=EC=9E=90=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/feature/home/ui/HomeBanner.kt | 2 +- .../buyornot/feature/home/ui/HomeScreen.kt | 45 +++++++++++-------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeBanner.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeBanner.kt index c45b8796..d7f3c634 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeBanner.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeBanner.kt @@ -86,7 +86,7 @@ fun HomeBanner( shape = RoundedCornerShape(HomeBannerDefaults.BannerCornerRadius), shadow = Shadow( - radius = 25.dp, + radius = 40.dp, color = Color(0xFFE0E3E5).copy(alpha = 0.6f), offset = DpOffset(x = 0.dp, y = 4.dp), ), 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 07bd6469..c706115e 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 @@ -360,6 +360,9 @@ private fun HomeFeedList( val listState = rememberLazyListState() val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible + val isAtTop by remember { + derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } + } var showLinkTooltip by remember { mutableStateOf(true) } val tooltipTargetIndex = @@ -415,34 +418,40 @@ private fun HomeFeedList( modifier = Modifier .fillMaxWidth() - .padding(top = contentPadding.calculateTopPadding()) - .background(BuyOrNotTheme.colors.gray0), + .padding(top = contentPadding.calculateTopPadding()), ) { - 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( + Column(modifier = Modifier.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, + ) + } + + HomeTabSection( userType = uiState.userType, - onLoginClick = onLoginClick, - onNotificationClick = onNotificationClick, - onProfileClick = onProfileClick, + selectedTab = uiState.selectedTab, + onTabSelected = { onIntent(HomeIntent.OnTabSelected(it)) }, ) } - HomeTabSection( - userType = uiState.userType, - selectedTab = uiState.selectedTab, - onTabSelected = { onIntent(HomeIntent.OnTabSelected(it)) }, - ) - 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 { + Column( + modifier = + Modifier.background( + if (isAtTop) Color.Transparent else BuyOrNotTheme.colors.gray0, + ), + ) { Spacer(modifier = Modifier.height(10.dp)) FilterChipRow( selectedCategories = uiState.selectedCategories, From 4a9f6712336310d1231ad03f513709495a15386f Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 28 Apr 2026 19:11:11 +0900 Subject: [PATCH 02/24] =?UTF-8?q?revert/#99:=20=EC=B5=9C=EC=83=81=EB=8B=A8?= =?UTF-8?q?=20FilterChipRow=20=ED=88=AC=EB=AA=85=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LazyColumn이 shadow를 clip하므로 FilterChipRow를 투명하게 해도 HomeBanner shadow가 header 영역까지 도달하지 않아 효과 없음 Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/feature/home/ui/HomeScreen.kt | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) 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 c706115e..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 @@ -360,9 +360,6 @@ private fun HomeFeedList( val listState = rememberLazyListState() val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible - val isAtTop by remember { - derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } - } var showLinkTooltip by remember { mutableStateOf(true) } val tooltipTargetIndex = @@ -418,40 +415,34 @@ private fun HomeFeedList( modifier = Modifier .fillMaxWidth() - .padding(top = contentPadding.calculateTopPadding()), + .padding(top = contentPadding.calculateTopPadding()) + .background(BuyOrNotTheme.colors.gray0), ) { - Column(modifier = Modifier.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, - ) - } - - HomeTabSection( + 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, - selectedTab = uiState.selectedTab, - onTabSelected = { onIntent(HomeIntent.OnTabSelected(it)) }, + onLoginClick = onLoginClick, + onNotificationClick = onNotificationClick, + onProfileClick = onProfileClick, ) } + HomeTabSection( + userType = uiState.userType, + selectedTab = uiState.selectedTab, + onTabSelected = { onIntent(HomeIntent.OnTabSelected(it)) }, + ) + 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( - modifier = - Modifier.background( - if (isAtTop) Color.Transparent else BuyOrNotTheme.colors.gray0, - ), - ) { + Column { Spacer(modifier = Modifier.height(10.dp)) FilterChipRow( selectedCategories = uiState.selectedCategories, From 918dfe63f2d0aae924dd09f8e4f580290c95f782 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 28 Apr 2026 19:11:15 +0900 Subject: [PATCH 03/24] =?UTF-8?q?feat/#99:=20HomeFeedList=EB=A5=BC=20Box?= =?UTF-8?q?=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=ED=95=B4=20=EB=B0=B0=EB=84=88=20shadow=20=EB=85=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LazyColumn을 Box의 첫 번째 자식으로 배치해 헤더 아래에 렌더링 - 헤더를 오버레이로 올려 LazyColumn contentPadding 상단 영역에서 shadow 가시화 - 최상단에서만 FilterChipRow 배경 투명 처리로 배너 shadow 투과 Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/feature/home/ui/HomeScreen.kt | 122 ++++++++++-------- 1 file changed, 69 insertions(+), 53 deletions(-) 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 07bd6469..a88532d3 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -52,6 +53,8 @@ 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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -360,6 +363,11 @@ private fun HomeFeedList( val listState = rememberLazyListState() val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible + var headerHeightPx by remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val isAtTop by remember { + derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } + } var showLinkTooltip by remember { mutableStateOf(true) } val tooltipTargetIndex = @@ -409,61 +417,16 @@ private fun HomeFeedList( onRefresh = { onIntent(HomeIntent.Refresh) }, modifier = modifier.fillMaxSize(), ) { - 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, - ) - } - - HomeTabSection( - userType = uiState.userType, - selectedTab = uiState.selectedTab, - onTabSelected = { onIntent(HomeIntent.OnTabSelected(it)) }, - ) - - 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)) - } - } - } - - // 스크롤 가능한 피드 목록 + Box(modifier = Modifier.fillMaxSize()) { + // 스크롤 가능한 피드 목록 (헤더 오버레이 아래에 렌더링) LazyColumn( state = listState, - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + top = with(density) { headerHeightPx.toDp() }, + bottom = contentPadding.calculateBottomPadding(), + ), horizontalAlignment = Alignment.CenterHorizontally, ) { // 배너 (투표 피드 탭이고 isBannerVisible이 true일 때만 표시) @@ -560,6 +523,59 @@ private fun HomeFeedList( } } } + + // 고정 헤더 영역 (오버레이: LazyColumn 위에 렌더링) + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = contentPadding.calculateTopPadding()) + .onGloballyPositioned { headerHeightPx = it.size.height }, + ) { + Column(modifier = Modifier.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, + ) + } + + HomeTabSection( + userType = uiState.userType, + selectedTab = uiState.selectedTab, + onTabSelected = { onIntent(HomeIntent.OnTabSelected(it)) }, + ) + } + + 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( + modifier = + Modifier.background( + if (isAtTop) Color.Transparent else BuyOrNotTheme.colors.gray0, + ), + ) { + 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)) + } + } + } } // 투표 상태 필터 시트 (PullToRefreshBox 최상단 → full-screen dim 적용) From cd0bae82b66f79e07f4c2b6269e4da74657c2858 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 5 May 2026 23:41:30 +0900 Subject: [PATCH 04/24] =?UTF-8?q?style/#103:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20EmptyView=20=EC=83=81=EB=8B=A8=20=EA=B0=84=EA=B2=A9?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt | 4 ++-- .../com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt index c2a66c53..34bf6321 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt @@ -3,7 +3,7 @@ package com.sseotdabwa.buyornot.core.data.di import com.sseotdabwa.buyornot.core.data.repository.AppPreferencesRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.AppUpdateRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.AuthRepositoryImpl -import com.sseotdabwa.buyornot.core.data.repository.FeedRepositoryImpl +import com.sseotdabwa.buyornot.core.data.repository.FakeFeedRepository import com.sseotdabwa.buyornot.core.data.repository.NotificationRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserPreferencesRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserRepositoryImpl @@ -29,7 +29,7 @@ internal abstract class DataModule { abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository @Binds - abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository + abstract fun bindFeedRepository(impl: FakeFeedRepository): FeedRepository @Binds abstract fun bindNotificationRepository(impl: NotificationRepositoryImpl): NotificationRepository 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 a88532d3..a4ed95f6 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 @@ -507,14 +507,14 @@ private fun HomeFeedList( item { if (uiState.selectedTab == HomeTab.MY_FEED) { HomeFeedEmptyView( - modifier = Modifier.padding(top = 140.dp), + modifier = Modifier.padding(top = 120.dp), title = "아직 올린 투표가 없어요", description = "고민되는 상품의 투표를 올려보세요!", onUploadClick = onUploadClick, ) } else { HomeFeedEmptyView( - modifier = Modifier.padding(top = 120.dp), + modifier = Modifier.padding(top = 100.dp), title = "첫번째 투표를 올려보세요!", onUploadClick = onUploadClick, ) From ff442e288b3440acf4886ac47488117b635027a3 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 5 May 2026 23:43:20 +0900 Subject: [PATCH 05/24] =?UTF-8?q?style/#103:=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=82=AC=EC=A7=84=20stroke=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 4848def5..92005136 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 @@ -11,6 +11,7 @@ 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.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -706,7 +707,12 @@ private fun SelectedImagePreview( modifier = Modifier .size(68.dp) - .clip(RoundedCornerShape(12.dp)), + .clip(RoundedCornerShape(12.dp)) + .border( + width = 1.dp, + color = BuyOrNotTheme.colors.gray300, + shape = RoundedCornerShape(12.dp), + ), contentAlignment = Alignment.TopEnd, ) { AsyncImage( From 717ee33533ff01a896801869a1aa0f1f49a2d86d Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 5 May 2026 23:50:08 +0900 Subject: [PATCH 06/24] =?UTF-8?q?style/#103:=20ActionSheet=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActionSheet의 LazyColumn 상하 패딩 값 조정 (top 26.dp, bottom 30.dp) - ActionSheetItem의 고정 높이를 제거하고 수직 패딩(6.dp) 추가 --- .../core/designsystem/components/ActionSheet.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 index 52fe55f5..79c1a348 100644 --- 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 @@ -6,7 +6,6 @@ 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 @@ -64,7 +63,11 @@ fun ActionSheet( LazyColumn( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(18.dp), - contentPadding = PaddingValues(vertical = 16.dp), + contentPadding = + PaddingValues( + top = 26.dp, + bottom = 30.dp, + ), ) { items( count = actions.size, @@ -92,9 +95,11 @@ private fun ActionItemRow( modifier = Modifier .fillMaxWidth() - .height(30.dp) .clickable(onClick = onClick) - .padding(horizontal = 24.dp), + .padding( + horizontal = 24.dp, + vertical = 6.dp, + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start, ) { From 62c4fb1460ed954935808016e1e049c4a95c21bc Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 5 May 2026 23:53:29 +0900 Subject: [PATCH 07/24] =?UTF-8?q?refactor/#103:=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=A4=91=EC=9D=B8=20`selectedFilter`=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A5=BC=20=20`HomeHeader`=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt | 2 -- 1 file changed, 2 deletions(-) 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 a4ed95f6..6a27ff0a 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 @@ -569,7 +569,6 @@ private fun HomeFeedList( 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)) @@ -604,7 +603,6 @@ private fun FilterChipRow( selectedCategories: Set, onAllCategorySelected: () -> Unit, onCategoryToggled: (FeedCategory) -> Unit, - selectedFilter: FilterChip, onShowSortSheet: () -> Unit, ) { val listState = rememberLazyListState() From e7a9410bd96ab6a6ae40e10203edc88051a8884b Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 5 May 2026 23:57:41 +0900 Subject: [PATCH 08/24] =?UTF-8?q?style/#103:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=8C=A8=EB=94=A9=20=EB=B0=8F=20=EA=B0=84?= =?UTF-8?q?=EA=B2=A9=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 6a27ff0a..ab87d158 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 @@ -674,8 +674,12 @@ private fun FilterChipRow( ) } }, - contentPadding = PaddingValues(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = + PaddingValues( + start = 6.dp, + end = 20.dp, + ), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { item { From 640411183394689772862dfdb2339f16500cb18a Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Tue, 5 May 2026 23:58:26 +0900 Subject: [PATCH 09/24] =?UTF-8?q?style/#103:=20NotificationScreen=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EA=B0=84=EA=B2=A9=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?(8.dp=20->=206.dp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buyornot/feature/notification/ui/NotificationScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 91b9a793..156669e3 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 @@ -286,7 +286,7 @@ private fun NotificationFilterRow( LazyRow( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 20.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { items(filters) { filter -> val filterText = From 487260328d873743f68c9b6bd8724bffda9dc870 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 6 May 2026 00:09:40 +0900 Subject: [PATCH 10/24] =?UTF-8?q?style/#103:=20ActionPopup=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=82=B4=EB=B6=80=20=EC=97=AC?= =?UTF-8?q?=EB=B0=B1=20=EC=A1=B0=EC=A0=95=20(vertical=20padding=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../buyornot/core/designsystem/components/ActionPopup.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ActionPopup.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ActionPopup.kt index 402a88a4..22b5a53b 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ActionPopup.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/ActionPopup.kt @@ -132,7 +132,11 @@ fun ActionPopupContent( ) { val pressedColor = BuyOrNotTheme.colors.gray200 Column( - modifier = Modifier.padding(6.dp), + modifier = + Modifier.padding( + horizontal = 6.dp, + vertical = 10.dp, + ), verticalArrangement = Arrangement.spacedBy(4.dp), ) { items.forEach { (label, onClick) -> From dc6c4a89a81368b6c8a0fb8bcc0bfbd9133a15a0 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Wed, 6 May 2026 00:13:41 +0900 Subject: [PATCH 11/24] =?UTF-8?q?feat/#103:=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A7=81=ED=81=AC=20=ED=88=B4=ED=8C=81=20dismiss?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 화면 상품 링크 툴팁의 노출 여부를 로컬 state에서 ViewModel의 `HomeState`로 이전 - 툴팁 dismiss 시 `HomeIntent.DismissTooltip`을 통해 상태를 업데이트하도록 수정 - `FeedCard` 및 `HomeScreen` 컴포넌트에 툴팁 dismiss 콜백 추가 및 연결 --- .../java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt | 4 ++-- .../buyornot/core/designsystem/components/FeedCard.kt | 6 +++++- .../com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt | 3 +++ .../com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt | 6 ++++-- .../sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt | 1 + 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt index 34bf6321..c2a66c53 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/di/DataModule.kt @@ -3,7 +3,7 @@ package com.sseotdabwa.buyornot.core.data.di import com.sseotdabwa.buyornot.core.data.repository.AppPreferencesRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.AppUpdateRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.AuthRepositoryImpl -import com.sseotdabwa.buyornot.core.data.repository.FakeFeedRepository +import com.sseotdabwa.buyornot.core.data.repository.FeedRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.NotificationRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserPreferencesRepositoryImpl import com.sseotdabwa.buyornot.core.data.repository.UserRepositoryImpl @@ -29,7 +29,7 @@ internal abstract class DataModule { abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository @Binds - abstract fun bindFeedRepository(impl: FakeFeedRepository): FeedRepository + abstract fun bindFeedRepository(impl: FeedRepositoryImpl): FeedRepository @Binds abstract fun bindNotificationRepository(impl: NotificationRepositoryImpl): NotificationRepository 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 c304b4e4..946d2994 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 @@ -86,6 +86,7 @@ fun FeedCard( productLink: String? = null, onLinkClick: (url: String) -> Unit = {}, showProductLinkTooltip: Boolean = false, + onTooltipDismiss: () -> Unit = {}, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { val hasVoted = userVotedOptionIndex != null @@ -140,7 +141,10 @@ fun FeedCard( price = price, productLink = productLink, showTooltip = tooltipVisible, - onTooltipDismiss = { tooltipVisible = false }, + onTooltipDismiss = { + tooltipVisible = false + onTooltipDismiss() + }, onFullscreenClick = { page -> onImageClick(productImageUrls, page) }, onLinkClick = onLinkClick, ) 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 0e83ef98..cb08b082 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 @@ -83,6 +83,7 @@ data class HomeUiState( val blockingNickname: String? = null, val blockingUserId: Long? = null, val showSortSheet: Boolean = false, + val isTooltipDismissed: Boolean = false, ) /** @@ -141,6 +142,8 @@ sealed interface HomeIntent { data object ShowSortSheet : HomeIntent data object DismissSortSheet : HomeIntent + + data object DismissTooltip : 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 ab87d158..85316d49 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 @@ -369,7 +369,6 @@ private fun HomeFeedList( derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } } - var showLinkTooltip by remember { mutableStateOf(true) } val tooltipTargetIndex = remember(filteredFeeds) { filteredFeeds.indexOfFirst { it.productLink != null } @@ -454,12 +453,13 @@ private fun HomeFeedList( voterProfileImageUrl = uiState.voterProfileImageUrl, isGuest = uiState.userType == UserType.GUEST, modifier = Modifier.animateItem(), - showProductLinkTooltip = showLinkTooltip && index == tooltipTargetIndex, + showProductLinkTooltip = !uiState.isTooltipDismissed && 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, + onTooltipDismissed = { onIntent(HomeIntent.DismissTooltip) }, onImageClick = onImageClick, ) } @@ -726,6 +726,7 @@ private fun FeedItemCard( onReport: (String) -> Unit, onBlock: (String) -> Unit, onLinkClick: (url: String) -> Unit, + onTooltipDismissed: () -> Unit = {}, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { Column { @@ -757,6 +758,7 @@ private fun FeedItemCard( productLink = feed.productLink, onLinkClick = onLinkClick, showProductLinkTooltip = showProductLinkTooltip, + onTooltipDismiss = onTooltipDismissed, onImageClick = onImageClick, ) 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 cdc74f4d..3ff2d522 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 @@ -132,6 +132,7 @@ class HomeViewModel @Inject constructor( } is HomeIntent.ShowSortSheet -> updateState { it.copy(showSortSheet = true) } is HomeIntent.DismissSortSheet -> updateState { it.copy(showSortSheet = false) } + is HomeIntent.DismissTooltip -> updateState { it.copy(isTooltipDismissed = true) } } } From d42beaf5bcf87a53711fb9de40c2470d17b193d8 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 7 May 2026 21:57:47 +0900 Subject: [PATCH 12/24] =?UTF-8?q?feat/#105:=20core/analytics=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EC=B6=94=EA=B0=80=20(Mixpanel=20+=20DebugAnalytics?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- core/analytics/build.gradle.kts | 43 +++++++++++++++ .../buyornot/core/analytics/Analytics.kt | 5 ++ .../buyornot/core/analytics/AnalyticsEvent.kt | 35 ++++++++++++ .../buyornot/core/analytics/DebugAnalytics.kt | 9 ++++ .../core/analytics/MixpanelAnalytics.kt | 54 +++++++++++++++++++ .../core/analytics/di/AnalyticsModule.kt | 35 ++++++++++++ gradle/libs.versions.toml | 6 +++ settings.gradle.kts | 1 + 8 files changed, 188 insertions(+) create mode 100644 core/analytics/build.gradle.kts create mode 100644 core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt create mode 100644 core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt create mode 100644 core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt create mode 100644 core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt create mode 100644 core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts new file mode 100644 index 00000000..4205f166 --- /dev/null +++ b/core/analytics/build.gradle.kts @@ -0,0 +1,43 @@ +import java.util.Properties + +plugins { + id("buyornot.android.library") + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +val localProperties = + Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { load(it) } + } + } + +android { + namespace = "com.sseotdabwa.buyornot.core.analytics" + + buildFeatures { + buildConfig = true + } + + buildTypes { + debug { + buildConfigField("String", "MIXPANEL_TOKEN", "\"\"") + } + release { + buildConfigField( + "String", + "MIXPANEL_TOKEN", + "\"${localProperties.getProperty("mixpanel.token", "")}\"", + ) + } + } +} + +dependencies { + implementation(libs.mixpanel.android) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt new file mode 100644 index 00000000..5c22ae9b --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt @@ -0,0 +1,5 @@ +package com.sseotdabwa.buyornot.core.analytics + +interface Analytics { + fun track(event: AnalyticsEvent) +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt new file mode 100644 index 00000000..8dc8ca0e --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt @@ -0,0 +1,35 @@ +package com.sseotdabwa.buyornot.core.analytics + +sealed class AnalyticsEvent { + data class FeedViewed( + val entrySource: String, + val firstVisibleItemIndex: Int, + ) : AnalyticsEvent() + + data class FeedExited( + val timeSpentSeconds: Float, + val lastVisibleItemIndex: Int, + ) : AnalyticsEvent() + + data class VoteSubmitted( + val feedId: Long, + val voteChoice: String, + val feedCategory: String, + ) : AnalyticsEvent() + + data class VoteCreateStarted( + val entrySource: String, + val isLoggedIn: Boolean, + ) : AnalyticsEvent() + + data class VoteCreateCompleted( + val itemId: Long, + val voteTitle: String, + val optionCount: Int, + ) : AnalyticsEvent() + + data class VoteCreateAbandoned( + val filledFields: List, + val lastStep: String?, + ) : AnalyticsEvent() +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt new file mode 100644 index 00000000..e14a5910 --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt @@ -0,0 +1,9 @@ +package com.sseotdabwa.buyornot.core.analytics + +import android.util.Log + +class DebugAnalytics : Analytics { + override fun track(event: AnalyticsEvent) { + Log.d("Analytics", event.toString()) + } +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt new file mode 100644 index 00000000..9c96a450 --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt @@ -0,0 +1,54 @@ +package com.sseotdabwa.buyornot.core.analytics + +import com.mixpanel.android.mpmetrics.MixpanelAPI +import org.json.JSONArray +import org.json.JSONObject + +class MixpanelAnalytics( + private val mixpanel: MixpanelAPI, +) : Analytics { + override fun track(event: AnalyticsEvent) { + val (name, props) = event.toMixpanel() + mixpanel.track(name, props) + } + + private fun AnalyticsEvent.toMixpanel(): Pair { + val props = JSONObject() + val name = + when (this) { + is AnalyticsEvent.FeedViewed -> { + props.put("entry_source", entrySource) + props.put("first_visible_item_index", firstVisibleItemIndex) + "feed_viewed" + } + is AnalyticsEvent.FeedExited -> { + props.put("time_spent_seconds", timeSpentSeconds) + props.put("last_visible_item_index", lastVisibleItemIndex) + "feed_exited" + } + is AnalyticsEvent.VoteSubmitted -> { + props.put("feed_id", feedId) + props.put("vote_choice", voteChoice) + props.put("feed_category", feedCategory) + "vote_submitted" + } + is AnalyticsEvent.VoteCreateStarted -> { + props.put("entry_source", entrySource) + props.put("is_logged_in", isLoggedIn) + "vote_create_started" + } + is AnalyticsEvent.VoteCreateCompleted -> { + props.put("item_id", itemId) + props.put("vote_title", voteTitle) + props.put("option_count", optionCount) + "vote_create_completed" + } + is AnalyticsEvent.VoteCreateAbandoned -> { + props.put("filled_fields", JSONArray(filledFields)) + if (lastStep != null) props.put("last_step", lastStep) + "vote_create_abandoned" + } + } + return name to props + } +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt new file mode 100644 index 00000000..591cc443 --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt @@ -0,0 +1,35 @@ +package com.sseotdabwa.buyornot.core.analytics.di + +import android.content.Context +import com.mixpanel.android.mpmetrics.MixpanelAPI +import com.sseotdabwa.buyornot.core.analytics.Analytics +import com.sseotdabwa.buyornot.core.analytics.BuildConfig +import com.sseotdabwa.buyornot.core.analytics.DebugAnalytics +import com.sseotdabwa.buyornot.core.analytics.MixpanelAnalytics +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AnalyticsModule { + @Provides + @Singleton + fun provideAnalytics( + @ApplicationContext context: Context, + ): Analytics = + if (BuildConfig.DEBUG) { + DebugAnalytics() + } else { + val mixpanel = + MixpanelAPI.getInstance( + context, + BuildConfig.MIXPANEL_TOKEN, + true, + ) + MixpanelAnalytics(mixpanel) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfd88eb5..3c61a291 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,9 @@ googleid = "1.2.0" lottie = "6.7.1" +# Analytics +mixpanel = "7.5.3" + # Firebase firebaseBom = "34.9.0" googleServices = "4.4.2" @@ -128,6 +131,9 @@ firebase-config = { group = "com.google.firebase", name = "firebase-config" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } +# Analytics +mixpanel-android = { group = "com.mixpanel.android", name = "mixpanel-android", version.ref = "mixpanel" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 56110bef..7f1ed466 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ dependencyResolutionManagement { rootProject.name = "BuyOrNot" include(":app") include(":domain") +include(":core:analytics") include(":core:data") include(":core:network") include(":core:datastore") From 70cc81aaf03ef581012cd5cdf277f9d578bdd0d3 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 7 May 2026 22:00:28 +0900 Subject: [PATCH 13/24] =?UTF-8?q?feat/#105:=20feature/home=20Analytics=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(feed=5Fviewed,=20feed=5Fexited,=20vote=5F?= =?UTF-8?q?submitted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- feature/home/build.gradle.kts | 1 + .../buyornot/feature/home/ui/HomeContract.kt | 9 ++++++ .../buyornot/feature/home/ui/HomeScreen.kt | 14 +++++++++ .../buyornot/feature/home/ui/HomeViewModel.kt | 29 +++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 32ab4eb6..8ab29cbb 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.core.analytics) implementation(projects.domain) implementation(projects.core.common) implementation(projects.core.designsystem) 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 cb08b082..2f0a66fd 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 @@ -144,6 +144,15 @@ sealed interface HomeIntent { data object DismissSortSheet : HomeIntent data object DismissTooltip : HomeIntent + + data class OnFeedScreenEntered( + val firstVisibleItemIndex: Int, + ) : HomeIntent + + data class OnFeedScreenExited( + val lastVisibleItemIndex: Int, + val timeSpentSeconds: Float, + ) : 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 85316d49..6a293e42 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 @@ -34,6 +34,7 @@ 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -363,6 +364,19 @@ private fun HomeFeedList( val listState = rememberLazyListState() val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible + + val enterTimeMs = remember { System.currentTimeMillis() } + DisposableEffect(Unit) { + onIntent(HomeIntent.OnFeedScreenEntered(firstVisibleItemIndex = listState.firstVisibleItemIndex)) + onDispose { + onIntent( + HomeIntent.OnFeedScreenExited( + lastVisibleItemIndex = listState.firstVisibleItemIndex, + timeSpentSeconds = (System.currentTimeMillis() - enterTimeMs) / 1000f, + ), + ) + } + } var headerHeightPx by remember { mutableIntStateOf(0) } val density = LocalDensity.current val isAtTop by remember { 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 3ff2d522..9a472852 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 @@ -2,6 +2,8 @@ package com.sseotdabwa.buyornot.feature.home.ui import android.util.Log import androidx.lifecycle.viewModelScope +import com.sseotdabwa.buyornot.core.analytics.Analytics +import com.sseotdabwa.buyornot.core.analytics.AnalyticsEvent import com.sseotdabwa.buyornot.core.common.util.TimeUtils import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio @@ -28,6 +30,7 @@ class HomeViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, private val feedRepository: FeedRepository, private val userRepository: UserRepository, + private val analytics: Analytics, ) : BaseViewModel(HomeUiState()) { private var currentUserId: Long? = null private var isUserIdLoaded = false @@ -133,6 +136,20 @@ class HomeViewModel @Inject constructor( is HomeIntent.ShowSortSheet -> updateState { it.copy(showSortSheet = true) } is HomeIntent.DismissSortSheet -> updateState { it.copy(showSortSheet = false) } is HomeIntent.DismissTooltip -> updateState { it.copy(isTooltipDismissed = true) } + is HomeIntent.OnFeedScreenEntered -> + analytics.track( + AnalyticsEvent.FeedViewed( + entrySource = "home", + firstVisibleItemIndex = intent.firstVisibleItemIndex, + ), + ) + is HomeIntent.OnFeedScreenExited -> + analytics.track( + AnalyticsEvent.FeedExited( + timeSpentSeconds = intent.timeSpentSeconds, + lastVisibleItemIndex = intent.lastVisibleItemIndex, + ), + ) } } @@ -250,6 +267,18 @@ class HomeViewModel @Inject constructor( targetFeed.userVotedOptionIndex != null -> return } + analytics.track( + AnalyticsEvent.VoteSubmitted( + feedId = targetFeed.id.toLong(), + voteChoice = if (optionIndex == 0) "YES" else "NO", + feedCategory = + FeedCategory.entries + .find { it.displayName == targetFeed.category } + ?.name + ?: targetFeed.category, + ), + ) + // 1. 낙관적 업데이트 (Optimistic Update) updateState { state -> val newAllFeeds = optimisticVoteUpdate(state.allFeeds, feedId, optionIndex) From 1386a293b672a7f112f2e88762bd134f28d08f64 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 7 May 2026 22:04:12 +0900 Subject: [PATCH 14/24] =?UTF-8?q?feat/#105:=20feature/upload=20Analytics?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20(vote=5Fcreate=5Fstarted,=20completed,?= =?UTF-8?q?=20abandoned)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- feature/upload/build.gradle.kts | 1 + .../feature/upload/ui/UploadContract.kt | 12 +++++ .../feature/upload/ui/UploadViewModel.kt | 48 ++++++++++++++++--- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/feature/upload/build.gradle.kts b/feature/upload/build.gradle.kts index ef861942..84fd97e5 100644 --- a/feature/upload/build.gradle.kts +++ b/feature/upload/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.core.analytics) implementation(projects.core.common) implementation(projects.domain) implementation(projects.core.designsystem) 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 8a9eee49..e3bc9291 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 @@ -18,6 +18,7 @@ data class UploadUiState( val showExitDialog: Boolean = false, val showPhotoPickerSheet: Boolean = false, val categories: List = FeedCategory.entries, + val lastTouchedField: String? = null, ) { val hasInput: Boolean get() = @@ -27,6 +28,17 @@ data class UploadUiState( link.isNotEmpty() || title.isNotEmpty() || content.isNotEmpty() + + val filledFields: List + get() = + buildList { + if (selectedImageUris.isNotEmpty()) add("images") + if (category != null) add("category") + if (price.isNotEmpty()) add("price") + if (link.isNotEmpty()) add("link") + if (title.isNotEmpty()) add("title") + if (content.isNotEmpty()) add("content") + } } sealed interface UploadIntent { 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 33d30979..67719e21 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 @@ -5,19 +5,38 @@ import android.graphics.BitmapFactory import android.net.Uri import android.provider.OpenableColumns import androidx.lifecycle.viewModelScope +import com.sseotdabwa.buyornot.core.analytics.Analytics +import com.sseotdabwa.buyornot.core.analytics.AnalyticsEvent 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.model.UserType import com.sseotdabwa.buyornot.domain.repository.FeedRepository +import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import com.sseotdabwa.buyornot.feature.upload.util.LinkValidator import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class UploadViewModel @Inject constructor( private val feedRepository: FeedRepository, + private val userPreferencesRepository: UserPreferencesRepository, + private val analytics: Analytics, ) : BaseViewModel(UploadUiState()) { + init { + viewModelScope.launch { + val userType = userPreferencesRepository.userType.first() + analytics.track( + AnalyticsEvent.VoteCreateStarted( + entrySource = "home", + isLoggedIn = userType == UserType.SOCIAL, + ), + ) + } + } + companion object { private const val MAX_IMAGE_COUNT = 3 private const val MAX_TITLE_LENGTH = 40 @@ -28,29 +47,29 @@ class UploadViewModel @Inject constructor( when (intent) { is UploadIntent.UpdateCategory -> updateState { - it.copy(category = intent.category, showCategorySheet = false) + it.copy(category = intent.category, showCategorySheet = false, lastTouchedField = "category") } is UploadIntent.UpdatePrice -> updateState { - it.copy(price = intent.digits, priceFieldValue = intent.textFieldValue) + it.copy(price = intent.digits, priceFieldValue = intent.textFieldValue, lastTouchedField = "price") } is UploadIntent.UpdateLink -> - updateState { it.copy(link = intent.link) } + updateState { it.copy(link = intent.link, lastTouchedField = "link") } is UploadIntent.UpdateTitle -> { if (intent.title.length <= MAX_TITLE_LENGTH) { - updateState { it.copy(title = intent.title) } + updateState { it.copy(title = intent.title, lastTouchedField = "title") } } } is UploadIntent.UpdateContent -> { if (intent.content.length <= MAX_CONTENT_LENGTH) { - updateState { it.copy(content = intent.content) } + updateState { it.copy(content = intent.content, lastTouchedField = "content") } } } 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) } + updateState { it.copy(selectedImageUris = it.selectedImageUris + toAdd, lastTouchedField = "images") } if (hasOverflow) sendSideEffect(UploadSideEffect.ShowSnackbar("최대 ${MAX_IMAGE_COUNT}장까지 추가할 수 있어요")) } is UploadIntent.RemoveImage -> @@ -71,6 +90,14 @@ class UploadViewModel @Inject constructor( it.copy(showPhotoPickerSheet = intent.isVisible) } UploadIntent.NavigateBack -> { + if (currentState.hasInput) { + analytics.track( + AnalyticsEvent.VoteCreateAbandoned( + filledFields = currentState.filledFields, + lastStep = currentState.lastTouchedField, + ), + ) + } updateState { it.copy(showExitDialog = false, showCategorySheet = false) } @@ -133,7 +160,14 @@ class UploadViewModel @Inject constructor( title = title, link = link, ) - }.onSuccess { + }.onSuccess { feedId -> + analytics.track( + AnalyticsEvent.VoteCreateCompleted( + itemId = feedId, + voteTitle = currentState.title, + optionCount = currentState.selectedImageUris.size, + ), + ) updateState { it.copy(isLoading = false) } sendSideEffect(UploadSideEffect.NavigateToHomeReview) }.onFailure { throwable -> From 8546b0d5658b8da0f7365bdf7e871be3738e53f7 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 7 May 2026 22:04:53 +0900 Subject: [PATCH 15/24] =?UTF-8?q?feat/#105:=20app=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20core:analytics=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(Hilt=20=EA=B7=B8=EB=9E=98=ED=94=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55c4ad9a..423ffeae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,7 @@ android { dependencies { implementation(projects.domain) + implementation(projects.core.analytics) implementation(projects.core.data) implementation(projects.core.network) implementation(projects.core.datastore) From 95acdce84e699b1984bbf06eb76ad544da1713e2 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 7 May 2026 22:24:32 +0900 Subject: [PATCH 16/24] =?UTF-8?q?refactor/#105:=20entrySource=20=EC=86=8D?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0=20(=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EA=B0=80=EC=B9=98=20=EC=97=86=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt | 2 -- .../com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt | 2 -- .../com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt | 1 - .../sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt | 1 - 4 files changed, 6 deletions(-) diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt index 8dc8ca0e..84445173 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt @@ -2,7 +2,6 @@ package com.sseotdabwa.buyornot.core.analytics sealed class AnalyticsEvent { data class FeedViewed( - val entrySource: String, val firstVisibleItemIndex: Int, ) : AnalyticsEvent() @@ -18,7 +17,6 @@ sealed class AnalyticsEvent { ) : AnalyticsEvent() data class VoteCreateStarted( - val entrySource: String, val isLoggedIn: Boolean, ) : AnalyticsEvent() diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt index 9c96a450..7e531560 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt @@ -17,7 +17,6 @@ class MixpanelAnalytics( val name = when (this) { is AnalyticsEvent.FeedViewed -> { - props.put("entry_source", entrySource) props.put("first_visible_item_index", firstVisibleItemIndex) "feed_viewed" } @@ -33,7 +32,6 @@ class MixpanelAnalytics( "vote_submitted" } is AnalyticsEvent.VoteCreateStarted -> { - props.put("entry_source", entrySource) props.put("is_logged_in", isLoggedIn) "vote_create_started" } 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 9a472852..a329f417 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 @@ -139,7 +139,6 @@ class HomeViewModel @Inject constructor( is HomeIntent.OnFeedScreenEntered -> analytics.track( AnalyticsEvent.FeedViewed( - entrySource = "home", firstVisibleItemIndex = intent.firstVisibleItemIndex, ), ) 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 67719e21..342fa30b 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 @@ -30,7 +30,6 @@ class UploadViewModel @Inject constructor( val userType = userPreferencesRepository.userType.first() analytics.track( AnalyticsEvent.VoteCreateStarted( - entrySource = "home", isLoggedIn = userType == UserType.SOCIAL, ), ) From b68fc35d32a9ddeeb6499528bbaf6873a2eb777d Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 7 May 2026 22:26:18 +0900 Subject: [PATCH 17/24] =?UTF-8?q?refactor/#105:=20FeedViewed=EC=97=90?= =?UTF-8?q?=EC=84=9C=20entrySource=20=EC=A0=9C=EA=B1=B0=20(=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=20=EC=A7=84=EC=9E=85=20=EA=B2=BD=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt | 1 + .../com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt | 1 + .../com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt | 1 + 3 files changed, 3 insertions(+) diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt index 84445173..5423d3c7 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt @@ -17,6 +17,7 @@ sealed class AnalyticsEvent { ) : AnalyticsEvent() data class VoteCreateStarted( + val entrySource: String, val isLoggedIn: Boolean, ) : AnalyticsEvent() diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt index 7e531560..792777cc 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt @@ -32,6 +32,7 @@ class MixpanelAnalytics( "vote_submitted" } is AnalyticsEvent.VoteCreateStarted -> { + props.put("entry_source", entrySource) props.put("is_logged_in", isLoggedIn) "vote_create_started" } 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 342fa30b..67719e21 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 @@ -30,6 +30,7 @@ class UploadViewModel @Inject constructor( val userType = userPreferencesRepository.userType.first() analytics.track( AnalyticsEvent.VoteCreateStarted( + entrySource = "home", isLoggedIn = userType == UserType.SOCIAL, ), ) From b81e21308c688450fa1d36e3c19bfb6e48d60f2c Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Thu, 7 May 2026 22:35:33 +0900 Subject: [PATCH 18/24] =?UTF-8?q?chore/#105:=20GitHub=20Actions=EC=97=90?= =?UTF-8?q?=20MIXPANEL=5FTOKEN=20secret=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/android-ci.yml | 1 + .github/workflows/distribute.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index e8ae2064..73a4ac73 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -38,6 +38,7 @@ jobs: echo "google.webClientId=${{ secrets.GOOGLE_WEB_CLIENT_ID }}" >> local.properties echo "kakao.nativeAppKey=${{ secrets.KAKAO_NATIVE_APP_KEY }}" >> local.properties echo "kakao.nativeAppKeyDebug=${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }}" >> local.properties + echo "mixpanel.token=${{ secrets.MIXPANEL_TOKEN }}" >> local.properties - name: Decode Keystore run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml index 75f22483..72ec5cec 100644 --- a/.github/workflows/distribute.yml +++ b/.github/workflows/distribute.yml @@ -56,6 +56,7 @@ jobs: echo "google.webClientId=${{ secrets.GOOGLE_WEB_CLIENT_ID }}" >> local.properties echo "kakao.nativeAppKey=${{ secrets.KAKAO_NATIVE_APP_KEY }}" >> local.properties echo "kakao.nativeAppKeyDebug=${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }}" >> local.properties + echo "mixpanel.token=${{ secrets.MIXPANEL_TOKEN }}" >> local.properties - name: Decode Keystore run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks From 3f1162da00063f7a3f03d195e130add26962ae50 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Fri, 8 May 2026 23:36:36 +0900 Subject: [PATCH 19/24] =?UTF-8?q?fix/#105:=20VoteSubmitted=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=A5=BC=20API=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/feature/home/ui/HomeViewModel.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) 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 a329f417..cc1f3a02 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 @@ -266,18 +266,6 @@ class HomeViewModel @Inject constructor( targetFeed.userVotedOptionIndex != null -> return } - analytics.track( - AnalyticsEvent.VoteSubmitted( - feedId = targetFeed.id.toLong(), - voteChoice = if (optionIndex == 0) "YES" else "NO", - feedCategory = - FeedCategory.entries - .find { it.displayName == targetFeed.category } - ?.name - ?: targetFeed.category, - ), - ) - // 1. 낙관적 업데이트 (Optimistic Update) updateState { state -> val newAllFeeds = optimisticVoteUpdate(state.allFeeds, feedId, optionIndex) @@ -296,6 +284,17 @@ class HomeViewModel @Inject constructor( UserType.GUEST -> feedRepository.voteGuestFeed(feedId.toLong(), choice) } }.onSuccess { voteResult -> + analytics.track( + AnalyticsEvent.VoteSubmitted( + feedId = targetFeed.id.toLong(), + voteChoice = if (optionIndex == 0) "YES" else "NO", + feedCategory = + FeedCategory.entries + .find { it.displayName == targetFeed.category } + ?.name + ?: targetFeed.category, + ), + ) // 2. 최종 업데이트: 서버 응답으로 확정 updateState { state -> val newAllFeeds = From a2998084edfa9b1961b7c298c0ff91b99e992e8e Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Fri, 8 May 2026 23:40:23 +0900 Subject: [PATCH 20/24] =?UTF-8?q?fix/#105:=20analytics.track=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8/=ED=99=94=EB=A9=B4=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/feature/home/ui/HomeViewModel.kt | 22 +++++++++---------- .../feature/upload/ui/UploadViewModel.kt | 12 +++++----- 2 files changed, 17 insertions(+), 17 deletions(-) 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 cc1f3a02..f56f4b13 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 @@ -284,17 +284,6 @@ class HomeViewModel @Inject constructor( UserType.GUEST -> feedRepository.voteGuestFeed(feedId.toLong(), choice) } }.onSuccess { voteResult -> - analytics.track( - AnalyticsEvent.VoteSubmitted( - feedId = targetFeed.id.toLong(), - voteChoice = if (optionIndex == 0) "YES" else "NO", - feedCategory = - FeedCategory.entries - .find { it.displayName == targetFeed.category } - ?.name - ?: targetFeed.category, - ), - ) // 2. 최종 업데이트: 서버 응답으로 확정 updateState { state -> val newAllFeeds = @@ -315,6 +304,17 @@ class HomeViewModel @Inject constructor( feeds = applyCategories(newAllFeeds, state.selectedCategories), ) } + analytics.track( + AnalyticsEvent.VoteSubmitted( + feedId = targetFeed.id.toLong(), + voteChoice = if (optionIndex == 0) "YES" else "NO", + feedCategory = + FeedCategory.entries + .find { it.displayName == targetFeed.category } + ?.name + ?: targetFeed.category, + ), + ) }.onFailure { e -> Log.e("HomeViewModel", "Failed to vote feed: $feedId", e) // 3. 롤백 (Rollback): 해당 피드만 원복, 나머지 동시 변경사항 보존 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 67719e21..084445c8 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 @@ -90,6 +90,10 @@ class UploadViewModel @Inject constructor( it.copy(showPhotoPickerSheet = intent.isVisible) } UploadIntent.NavigateBack -> { + updateState { + it.copy(showExitDialog = false, showCategorySheet = false) + } + sendSideEffect(UploadSideEffect.NavigateBack) if (currentState.hasInput) { analytics.track( AnalyticsEvent.VoteCreateAbandoned( @@ -98,10 +102,6 @@ class UploadViewModel @Inject constructor( ), ) } - updateState { - it.copy(showExitDialog = false, showCategorySheet = false) - } - sendSideEffect(UploadSideEffect.NavigateBack) } } } @@ -161,6 +161,8 @@ class UploadViewModel @Inject constructor( link = link, ) }.onSuccess { feedId -> + updateState { it.copy(isLoading = false) } + sendSideEffect(UploadSideEffect.NavigateToHomeReview) analytics.track( AnalyticsEvent.VoteCreateCompleted( itemId = feedId, @@ -168,8 +170,6 @@ class UploadViewModel @Inject constructor( optionCount = currentState.selectedImageUris.size, ), ) - updateState { it.copy(isLoading = false) } - sendSideEffect(UploadSideEffect.NavigateToHomeReview) }.onFailure { throwable -> updateState { it.copy(isLoading = false) } sendSideEffect(UploadSideEffect.ShowSnackbar("업로드에 실패했습니다.")) From bab7f20112578689652a6f7f1b4300e389ff6ea3 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 9 May 2026 16:38:56 +0900 Subject: [PATCH 21/24] =?UTF-8?q?fix/#107:=20FORCE=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=EC=8B=9C=20latestVersion=20=EC=9D=B4=EC=83=81=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9D=80=20=EA=B0=95=EC=A0=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index 8d908e24..f44ef634 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -99,7 +99,8 @@ class SplashViewModel @Inject constructor( return when { currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.FORCE -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.FORCE && + currentVersion < updateInfo.latestVersion -> UpdateDialogType.Force updateInfo.updateStrategy == UpdateStrategy.SOFT && currentVersion < updateInfo.latestVersion -> { val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() From ac10c1bab6213b0dfd0fbe59db7074e4cd45e8c2 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 9 May 2026 16:52:31 +0900 Subject: [PATCH 22/24] =?UTF-8?q?fix/#107:=20=EC=8B=9C=EA=B3=84=20?= =?UTF-8?q?=EC=97=AD=ED=96=89=20=EC=B2=98=EB=A6=AC,=20dismiss=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BB=A4=EB=B2=84?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .editorconfig | 3 + feature/auth/build.gradle.kts | 4 + .../feature/auth/ui/SplashViewModel.kt | 49 +++-- .../feature/auth/ui/SplashUpdateLogicTest.kt | 202 ++++++++++++++++++ 4 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt diff --git a/.editorconfig b/.editorconfig index aa422545..b1a46d4a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,3 +8,6 @@ trim_trailing_whitespace = true [*.{kt,kts}] ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_annotation = disabled + +[*Test.kt] +ktlint_standard_function-naming = disabled diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 989d47a6..0dfa0a8c 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -33,4 +33,8 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + + testImplementation(libs.junit) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index f44ef634..69567e3e 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -20,9 +20,34 @@ import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException private const val SPLASH_TIMEOUT_MILLIS = 2300L -private const val SOFT_UPDATE_INTERVAL_MILLIS = 24 * 60 * 60 * 1000L +internal const val SOFT_UPDATE_INTERVAL_MILLIS = 24 * 60 * 60 * 1000L private const val TAG = "SplashUpdate" +internal fun resolveUpdateDialogType( + currentVersion: Int, + updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, + lastSoftUpdateShownTime: Long, + now: Long, +): UpdateDialogType { + if (updateInfo == null) return UpdateDialogType.None + + return when { + currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.FORCE && + currentVersion < updateInfo.latestVersion -> UpdateDialogType.Force + updateInfo.updateStrategy == UpdateStrategy.SOFT && + currentVersion < updateInfo.latestVersion -> { + val effectiveLastShown = if (lastSoftUpdateShownTime > now) 0L else lastSoftUpdateShownTime + if (now - effectiveLastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { + UpdateDialogType.Soft + } else { + UpdateDialogType.None + } + } + else -> UpdateDialogType.None + } +} + /** * 스플래시 화면을 위한 ViewModel * @@ -95,29 +120,17 @@ class SplashViewModel @Inject constructor( currentVersion: Int, updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, ): UpdateDialogType { - if (updateInfo == null) return UpdateDialogType.None - - return when { - currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.FORCE && - currentVersion < updateInfo.latestVersion -> UpdateDialogType.Force - updateInfo.updateStrategy == UpdateStrategy.SOFT && - currentVersion < updateInfo.latestVersion -> { - val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() - if (System.currentTimeMillis() - lastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { - UpdateDialogType.Soft - } else { - UpdateDialogType.None - } - } - else -> UpdateDialogType.None - } + val now = System.currentTimeMillis() + val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() + return resolveUpdateDialogType(currentVersion, updateInfo, lastShown, now) } private fun dismissSoftUpdate() { viewModelScope.launch { try { appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + } catch (e: Exception) { + Log.e(TAG, "Failed to save soft update shown time", e) } finally { updateState { it.copy(updateDialogType = UpdateDialogType.None) } } diff --git a/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt b/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt new file mode 100644 index 00000000..58933c4f --- /dev/null +++ b/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt @@ -0,0 +1,202 @@ +package com.sseotdabwa.buyornot.feature.auth.ui + +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo +import com.sseotdabwa.buyornot.domain.model.UpdateStrategy +import org.junit.Test +import kotlin.test.assertEquals + +class SplashUpdateLogicTest { + private val now = 1_000_000_000L + + @Test + fun updateInfo가_null이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 10, + updateInfo = null, + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun currentVersion이_minimumVersion_미만이면_Force를_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 5, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 6, + updateStrategy = UpdateStrategy.NONE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.Force, result) + } + + @Test + fun FORCE_전략이고_currentVersion이_latestVersion_미만이면_Force를_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.FORCE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.Force, result) + } + + @Test + fun FORCE_전략이지만_currentVersion이_latestVersion_이상이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 10, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.FORCE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun FORCE_전략이고_minimumVersion_이상_latestVersion_미만이면_Force를_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 7, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.FORCE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.Force, result) + } + + @Test + fun SOFT_전략이고_24시간_이상_지났으면_Soft를_반환한다() { + val lastShown = now - SOFT_UPDATE_INTERVAL_MILLIS + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = lastShown, + now = now, + ) + assertEquals(UpdateDialogType.Soft, result) + } + + @Test + fun SOFT_전략이지만_24시간_미만이면_None을_반환한다() { + val lastShown = now - SOFT_UPDATE_INTERVAL_MILLIS + 1 + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = lastShown, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun SOFT_전략이지만_currentVersion이_latestVersion_이상이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 10, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun 시계가_역행했을때_lastShown이_무효화되어_Soft를_반환한다() { + // lastSoftUpdateShownTime이 now보다 미래인 경우 (시계 역행) → effectiveLastShown = 0 → 항상 Soft + val lastShownInFuture = now + 1_000L + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = lastShownInFuture, + now = now, + ) + assertEquals(UpdateDialogType.Soft, result) + } + + @Test + fun NONE_전략이면_None을_반환한다() { + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.NONE, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals(UpdateDialogType.None, result) + } + + @Test + fun minimumVersion_미달이면_전략_무관하게_Force를_반환한다() { + for (strategy in UpdateStrategy.entries) { + val result = + resolveUpdateDialogType( + currentVersion = 3, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = strategy, + ), + lastSoftUpdateShownTime = 0L, + now = now, + ) + assertEquals( + UpdateDialogType.Force, + result, + "strategy=$strategy 일 때 minimumVersion 미달은 Force여야 함", + ) + } + } +} From e5b1f1ffbd7344b0b0c7d66e44d6de55b6dc01e3 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 9 May 2026 17:12:18 +0900 Subject: [PATCH 23/24] =?UTF-8?q?fix/#107:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=20CancellationExcep?= =?UTF-8?q?tion=20=EC=9E=AC=EC=A0=84=ED=8C=8C,=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20=EC=B5=9C=EC=B4=88=20=EC=84=A4=EC=B9=98?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/auth/ui/SplashViewModel.kt | 9 +++++++-- .../feature/auth/ui/SplashUpdateLogicTest.kt | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index 69567e3e..c0ab8838 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.core.content.pm.PackageInfoCompat import androidx.lifecycle.viewModelScope import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel +import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo import com.sseotdabwa.buyornot.domain.model.UpdateStrategy import com.sseotdabwa.buyornot.domain.model.UserType import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository @@ -25,7 +26,7 @@ private const val TAG = "SplashUpdate" internal fun resolveUpdateDialogType( currentVersion: Int, - updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, + updateInfo: AppUpdateInfo?, lastSoftUpdateShownTime: Long, now: Long, ): UpdateDialogType { @@ -33,10 +34,12 @@ internal fun resolveUpdateDialogType( return when { currentVersion < updateInfo.minimumVersion -> UpdateDialogType.Force + // currentVersion >= latestVersion이면 이미 최신 버전이므로 FORCE 팝업 표시 안 함 updateInfo.updateStrategy == UpdateStrategy.FORCE && currentVersion < updateInfo.latestVersion -> UpdateDialogType.Force updateInfo.updateStrategy == UpdateStrategy.SOFT && currentVersion < updateInfo.latestVersion -> { + // lastSoftUpdateShownTime이 미래 값이면 시계 역행으로 판단, 표시된 적 없는 것으로 처리 val effectiveLastShown = if (lastSoftUpdateShownTime > now) 0L else lastSoftUpdateShownTime if (now - effectiveLastShown >= SOFT_UPDATE_INTERVAL_MILLIS) { UpdateDialogType.Soft @@ -118,7 +121,7 @@ class SplashViewModel @Inject constructor( private suspend fun determineDialogType( currentVersion: Int, - updateInfo: com.sseotdabwa.buyornot.domain.model.AppUpdateInfo?, + updateInfo: AppUpdateInfo?, ): UpdateDialogType { val now = System.currentTimeMillis() val lastShown = appPreferencesRepository.lastSoftUpdateShownTime.first() @@ -129,6 +132,8 @@ class SplashViewModel @Inject constructor( viewModelScope.launch { try { appPreferencesRepository.updateLastSoftUpdateShownTime(System.currentTimeMillis()) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.e(TAG, "Failed to save soft update shown time", e) } finally { diff --git a/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt b/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt index 58933c4f..32206277 100644 --- a/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt +++ b/feature/auth/src/test/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashUpdateLogicTest.kt @@ -199,4 +199,22 @@ class SplashUpdateLogicTest { ) } } + + @Test + fun 최초_설치시_소프트_업데이트를_표시한다() { + // DataStore 기본값(0L)일 때 now - 0 >= SOFT_UPDATE_INTERVAL_MILLIS 이므로 Soft 반환 + val result = + resolveUpdateDialogType( + currentVersion = 8, + updateInfo = + AppUpdateInfo( + latestVersion = 10, + minimumVersion = 5, + updateStrategy = UpdateStrategy.SOFT, + ), + lastSoftUpdateShownTime = 0L, + now = SOFT_UPDATE_INTERVAL_MILLIS + 1L, + ) + assertEquals(UpdateDialogType.Soft, result) + } } From 0dca3e7a6ac6a995532477009648843d8fa63be3 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sat, 9 May 2026 17:25:46 +0900 Subject: [PATCH 24/24] chore: 0.3.0 (6) -> 0.3.1 (7) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 423ffeae..173384ab 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { defaultConfig { applicationId = "com.sseotdabwa.buyornot" - versionCode = 6 - versionName = "0.3.0" + versionCode = 7 + versionName = "0.3.1" buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${localProperties.getProperty("kakao.nativeAppKey", "")}\"") manifestPlaceholders["NATIVE_APP_KEY"] = localProperties.getProperty("kakao.nativeAppKey", "")