Skip to content

Commit fdcb31a

Browse files
authored
Merge pull request #481 from BCSDLab/feature/keyword-noti-abtest
[Feature] 홈화면 키워드 알림 배너 AB테스트
2 parents fa1d7ca + 3d3b65a commit fdcb31a

File tree

19 files changed

+376
-82
lines changed

19 files changed

+376
-82
lines changed

core/src/main/java/in/koreatech/koin/core/abtest/Experiment.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ enum class Experiment(
77

88
BENEFIT_STORE("Benefit", ExperimentGroup.A, ExperimentGroup.B),
99
DINING_SHARE("campus_share_v1", ExperimentGroup.SHARE_ORIGINAL, ExperimentGroup.SHARE_NEW),
10-
MAIN_DINING_SEE_MORE("c_main_dining_v1", ExperimentGroup.MAIN_DINING_ORIGINAL, ExperimentGroup.MAIN_DINING_NEW);
10+
MAIN_DINING_SEE_MORE("c_main_dining_v1", ExperimentGroup.MAIN_DINING_ORIGINAL, ExperimentGroup.MAIN_DINING_NEW),
11+
MAIN_ARTICLE_KEYWORD_BANNER("c_keyword_ banner_v1", ExperimentGroup.MAIN_BANNER_ORIGINAL, ExperimentGroup.MAIN_BANNER_NEW);
1112

1213
init {
1314
require(experimentGroups.isNotEmpty()) { "Experiment should have at least one group" }
@@ -23,4 +24,7 @@ object ExperimentGroup {
2324

2425
const val MAIN_DINING_ORIGINAL = "main_dining_original"
2526
const val MAIN_DINING_NEW = "main_dining_new"
27+
28+
const val MAIN_BANNER_ORIGINAL = "banner_original"
29+
const val MAIN_BANNER_NEW = "banner_new"
2630
}

core/src/main/java/in/koreatech/koin/core/constant/AnalyticsConstant.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ object AnalyticsConstant {
9999

100100
const val CAMPUS_DINING_1 = "CAMPUS_dining_1"
101101
const val CAMPUS_NOTICE_1 = "CAMPUS_notice_1"
102+
const val POPULAR_NOTICE_BANNER = "popular_notice_banner"
103+
const val TO_MANAGE_KEYWORD = "to_manage_keyword"
102104
}
103105

104106
const val PREVIOUS_PAGE = "previous_page"
Loading
Loading
Loading

core/src/main/res/values/colors.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@
142142
<color name="primary_100">#CFF1F9</color>
143143
<!-- Sub -->
144144
<color name="sub_sub500">#F7941E</color>
145+
<color name="sub_sub600">#D47415</color>
145146
<!-- Neutral -->
146147
<color name="neutral_800">#000000</color>
147148
<color name="neutral_700">#1F1F1F</color>

data/src/main/java/in/koreatech/koin/data/constant/Constant.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ const val BUS_REQUEST_TIME_FORMAT = "HH:mm"
66

77
const val STORE_OPEN_TIME_FORMAT = "HH:mm"
88
const val STORE_CLOSE_TIME_FORMAT = "HH:mm"
9-
const val STORE_UPDATED_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss" // 2018-03-23 20:25:24
9+
const val STORE_UPDATED_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss" // 2018-03-23 20:25:24
10+
11+
const val WEEK_IN_MILLIS = 7 * 24 * 60 * 60 * 1000L

data/src/main/java/in/koreatech/koin/data/repository/ArticleRepositoryImpl.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import `in`.koreatech.koin.data.source.local.ArticleLocalDataSource
55
import `in`.koreatech.koin.data.source.remote.ArticleRemoteDataSource
66
import `in`.koreatech.koin.domain.model.article.Article
77
import `in`.koreatech.koin.domain.model.article.ArticleHeader
8+
import `in`.koreatech.koin.domain.model.article.ArticleNoti
89
import `in`.koreatech.koin.domain.model.article.ArticlePagination
10+
import `in`.koreatech.koin.domain.model.article.articleNotiContent
911
import `in`.koreatech.koin.domain.model.user.User
1012
import `in`.koreatech.koin.domain.repository.ArticleRepository
1113
import `in`.koreatech.koin.domain.repository.UserRepository
@@ -149,6 +151,18 @@ class ArticleRepositoryImpl @Inject constructor(
149151
}
150152
}
151153

154+
override fun fetchKeywordNoti(): Flow<ArticleNoti> {
155+
return flow {
156+
emit(articleNotiContent[articleLocalDataSource.fetchKeywordNotiIndex()])
157+
}
158+
}
159+
160+
override fun saveKeywordNotiIndex(): Flow<Unit> {
161+
return flow {
162+
emit(articleLocalDataSource.saveKeywordNotiIndex())
163+
}
164+
}
165+
152166
override fun fetchSearchedArticles(
153167
query: String,
154168
boardId: Int,

data/src/main/java/in/koreatech/koin/data/source/datastore/ArticleDataStore.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ package `in`.koreatech.koin.data.source.datastore
33
import androidx.datastore.core.DataStore
44
import androidx.datastore.preferences.core.Preferences
55
import androidx.datastore.preferences.core.edit
6+
import androidx.datastore.preferences.core.intPreferencesKey
7+
import androidx.datastore.preferences.core.longPreferencesKey
68
import androidx.datastore.preferences.core.stringPreferencesKey
79
import com.google.gson.Gson
10+
import `in`.koreatech.koin.data.constant.WEEK_IN_MILLIS
11+
import `in`.koreatech.koin.domain.model.article.articleNotiContent
812
import kotlinx.coroutines.flow.Flow
913
import kotlinx.coroutines.flow.first
1014
import kotlinx.coroutines.flow.flowOf
@@ -77,8 +81,28 @@ class ArticleDataStore @Inject constructor(
7781
}
7882
}
7983

84+
suspend fun fetchKeywordNotiIndex(): Int {
85+
return dataStore.data.first()[KEY_NOTI_INDEX] ?: 0
86+
}
87+
88+
suspend fun saveKeywordNotiIndex() {
89+
dataStore.edit { preferences ->
90+
var notiIndex = preferences[KEY_NOTI_INDEX] ?: 0
91+
val lastUpdateTime = preferences[KEY_NOTI_LAST_UPDATE] ?: 0L
92+
val currentTime = System.currentTimeMillis()
93+
94+
if (currentTime - lastUpdateTime >= WEEK_IN_MILLIS) {
95+
notiIndex = (notiIndex + 1) % articleNotiContent.size
96+
preferences[KEY_NOTI_INDEX] = notiIndex
97+
preferences[KEY_NOTI_LAST_UPDATE] = lastUpdateTime
98+
}
99+
}
100+
}
101+
80102
companion object {
81103
private val KEY_SEARCH_HISTORY = stringPreferencesKey("search_history")
82104
private val KEY_MY_KEYWORD = stringPreferencesKey("my_keyword")
105+
private val KEY_NOTI_INDEX = intPreferencesKey("noti_index")
106+
private val KEY_NOTI_LAST_UPDATE = longPreferencesKey("noti_last_update_time")
83107
}
84108
}

data/src/main/java/in/koreatech/koin/data/source/local/ArticleLocalDataSource.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,12 @@ class ArticleLocalDataSource @Inject constructor(
3535
suspend fun deleteKeyword(keyword: String) {
3636
articleDataStore.deleteKeyword(keyword)
3737
}
38+
39+
suspend fun fetchKeywordNotiIndex(): Int {
40+
return articleDataStore.fetchKeywordNotiIndex()
41+
}
42+
43+
suspend fun saveKeywordNotiIndex() {
44+
articleDataStore.saveKeywordNotiIndex()
45+
}
3846
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package `in`.koreatech.koin.domain.model.article
2+
3+
data class ArticleNoti (
4+
val title: String,
5+
val sub: String,
6+
val value: String
7+
)
8+
9+
val articleNotiContent = listOf(
10+
ArticleNoti("자취방 양도글, 가장 먼저 확인하고 싶을 때?", "공지가 업로드 되면 바로 알려주는\n키워드 알림 설정하러가기", "자취방 양도"),
11+
ArticleNoti("키워드가 포함된 공지가 업로드 되면\n가장 먼저 알림을 보내드려요!", "키워드 알림 설정 바로가기", "안내글"),
12+
ArticleNoti("근로 공지, 놓치고 싶지 않다면?", "공지가 업로드 되면 바로 알려주는\n키워드 알림 설정하러가기", "근로"),
13+
ArticleNoti("해외탐방 공지, 놓치고 싶지 않다면?", "공지가 업로드 되면 바로 알려주는\n키워드 알림 설정하러가기", "해외탐방")
14+
)

domain/src/main/java/in/koreatech/koin/domain/repository/ArticleRepository.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package `in`.koreatech.koin.domain.repository
22

33
import `in`.koreatech.koin.domain.model.article.Article
44
import `in`.koreatech.koin.domain.model.article.ArticleHeader
5+
import `in`.koreatech.koin.domain.model.article.ArticleNoti
56
import `in`.koreatech.koin.domain.model.article.ArticlePagination
67
import kotlinx.coroutines.flow.Flow
78

@@ -15,6 +16,8 @@ interface ArticleRepository {
1516
fun fetchKeywordSuggestions(): Flow<List<String>>
1617
fun saveKeyword(keyword: String): Flow<Unit>
1718
fun deleteKeyword(keyword: String): Flow<Unit>
19+
fun fetchKeywordNoti(): Flow<ArticleNoti>
20+
fun saveKeywordNotiIndex(): Flow<Unit>
1821
fun fetchSearchedArticles(query: String, boardId: Int, page: Int, limit: Int): Flow<ArticlePagination>
1922
fun fetchMostSearchedKeywords(count: Int): Flow<List<String>>
2023
fun fetchSearchHistory(): Flow<List<String>>

koin/src/main/java/in/koreatech/koin/ui/main/activity/MainActivity.kt

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,16 @@ import `in`.koreatech.koin.ui.bus.BusActivity
5252
import `in`.koreatech.koin.ui.dining.DiningActivity
5353
import `in`.koreatech.koin.ui.main.adapter.BusPagerAdapter
5454
import `in`.koreatech.koin.ui.main.adapter.DiningContainerViewPager2Adapter
55-
import `in`.koreatech.koin.ui.main.adapter.HotArticleAdapter
55+
import `in`.koreatech.koin.ui.main.adapter.ArticleMainAdapter
5656
import `in`.koreatech.koin.ui.main.adapter.StoreCategoriesRecyclerAdapter
57+
import `in`.koreatech.koin.ui.main.state.ArticleMainState
5758
import `in`.koreatech.koin.ui.main.viewmodel.MainActivityViewModel
5859
import `in`.koreatech.koin.ui.navigation.KoinNavigationDrawerTimeActivity
5960
import `in`.koreatech.koin.ui.navigation.state.MenuState
6061
import `in`.koreatech.koin.ui.store.activity.CallBenefitStoreActivity
6162
import `in`.koreatech.koin.ui.store.contract.StoreActivityContract
6263
import `in`.koreatech.koin.util.ext.observeLiveData
64+
import kotlinx.coroutines.flow.collectLatest
6365
import kotlinx.coroutines.launch
6466
import javax.inject.Inject
6567

@@ -78,11 +80,19 @@ class MainActivity : KoinNavigationDrawerTimeActivity() {
7880
@Inject
7981
lateinit var onboardingManager: OnboardingManager
8082

81-
private val hotArticleAdapter = HotArticleAdapter(
82-
onClick = {
83+
private val articleMainAdapter = ArticleMainAdapter(
84+
onNotiClick = {
85+
EventLogger.logClickEvent(EventAction.CAMPUS, AnalyticsConstant.Label.TO_MANAGE_KEYWORD, it.value)
86+
val intent = Intent(Intent.ACTION_VIEW).apply {
87+
data = Uri.parse("koin://article/activity?fragment=article_keyword")
88+
}
89+
startActivity(intent)
90+
},
91+
onArticleClick = {
92+
EventLogger.logClickEvent(EventAction.CAMPUS, AnalyticsConstant.Label.POPULAR_NOTICE_BANNER, it.title)
8393
val intent = Intent(Intent.ACTION_VIEW).apply {
8494
data =
85-
Uri.parse("koin://article/activity?fragment=article_detail&article_id=${it.id}&board_id=${it.board.id}")
95+
Uri.parse("koin://article/activity?fragment=article_detail&article_id=${it.id}&board_id=${it.boardId}")
8696
}
8797
startActivity(intent)
8898
}
@@ -166,6 +176,7 @@ class MainActivity : KoinNavigationDrawerTimeActivity() {
166176
}
167177

168178
private fun initView() = with(binding) {
179+
initArticleBannerABTest()
169180
initDiningABTest()
170181
binding.nestedScrollViewMain.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY ->
171182
val offset = binding.nestedScrollViewMain.computeVerticalScrollOffset()
@@ -207,7 +218,7 @@ class MainActivity : KoinNavigationDrawerTimeActivity() {
207218
}
208219

209220
viewPagerHotArticle.apply {
210-
adapter = hotArticleAdapter
221+
adapter = articleMainAdapter
211222
offscreenPageLimit = 3
212223
enableAutoScroll(this@MainActivity, 5_000)
213224
}
@@ -245,7 +256,9 @@ class MainActivity : KoinNavigationDrawerTimeActivity() {
245256
val intent = Intent(this@MainActivity, BusSearchActivity::class.java)
246257
startActivity(intent)
247258
},
248-
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)
259+
modifier = Modifier
260+
.fillMaxWidth()
261+
.padding(horizontal = 20.dp)
249262
)
250263
}
251264
}
@@ -289,13 +302,6 @@ class MainActivity : KoinNavigationDrawerTimeActivity() {
289302
}
290303

291304
private fun initViewModel() = with(viewModel) {
292-
lifecycleScope.launch {
293-
repeatOnLifecycle(Lifecycle.State.STARTED) {
294-
viewModel.hotArticles.collect {
295-
hotArticleAdapter.submitList(it)
296-
}
297-
}
298-
}
299305
observeLiveData(isLoading) {
300306
binding.mainSwipeRefreshLayout.isRefreshing = it
301307
}
@@ -421,10 +427,31 @@ class MainActivity : KoinNavigationDrawerTimeActivity() {
421427
}
422428
}
423429

430+
private fun initArticleBannerABTest() {
431+
lifecycleScope.launch {
432+
repeatOnLifecycle(Lifecycle.State.STARTED) {
433+
viewModel.bannerABTestExperimentGroup.collectLatest {
434+
when (it) {
435+
ExperimentGroup.MAIN_BANNER_NEW -> {
436+
viewModel.articleMain.collectLatest {
437+
articleMainAdapter.submitList(it)
438+
}
439+
}
440+
ExperimentGroup.MAIN_BANNER_ORIGINAL -> {
441+
viewModel.hotArticles.collectLatest {
442+
articleMainAdapter.submitList(it)
443+
}
444+
}
445+
}
446+
}
447+
}
448+
}
449+
}
450+
424451
private fun initDiningABTest() {
425452
binding.textSeeMoreDining.setOnClickListener {
426453
Intent(this, DiningActivity::class.java).run {
427-
startActivity(this)
454+
startActivity(this)
428455
}
429456
}
430457
lifecycleScope.launch {
@@ -434,6 +461,7 @@ class MainActivity : KoinNavigationDrawerTimeActivity() {
434461
ExperimentGroup.MAIN_DINING_NEW -> {
435462
binding.textSeeMoreDining.visibility = View.VISIBLE
436463
}
464+
437465
ExperimentGroup.MAIN_DINING_ORIGINAL -> {
438466
binding.textSeeMoreDining.visibility = View.GONE
439467
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package `in`.koreatech.koin.ui.main.adapter
2+
3+
import android.view.LayoutInflater
4+
import android.view.ViewGroup
5+
import androidx.recyclerview.widget.DiffUtil
6+
import androidx.recyclerview.widget.ListAdapter
7+
import androidx.recyclerview.widget.RecyclerView
8+
import `in`.koreatech.koin.databinding.MainCardArticleBinding
9+
import `in`.koreatech.koin.databinding.MainCardArticleNotiBinding
10+
import `in`.koreatech.koin.ui.main.state.ArticleMainState
11+
12+
class ArticleMainAdapter(
13+
private val onNotiClick: (ArticleMainState.Noti) -> Unit,
14+
private val onArticleClick: (ArticleMainState.Content) -> Unit
15+
) :
16+
ListAdapter<ArticleMainState, RecyclerView.ViewHolder>(diffCallback) {
17+
18+
inner class KeywordNotiViewHolder(
19+
private val binding: MainCardArticleNotiBinding
20+
) : RecyclerView.ViewHolder(binding.root) {
21+
fun bind(content: ArticleMainState.Noti) {
22+
binding.textArticleNotiTitle.text = content.title
23+
binding.textArticleNotiSub.text = content.sub
24+
binding.cardViewArticleNoti.setOnClickListener { onNotiClick(content) }
25+
}
26+
}
27+
28+
inner class HotArticleViewHolder(
29+
private val binding: MainCardArticleBinding
30+
) : RecyclerView.ViewHolder(binding.root) {
31+
32+
fun bind(content: ArticleMainState.Content) {
33+
binding.textArticleTitle.text = content.title
34+
binding.cardViewArticleHeader.setOnClickListener { onArticleClick(content) }
35+
}
36+
}
37+
38+
override fun getItemViewType(position: Int): Int {
39+
return when (getItem(position)) {
40+
is ArticleMainState.Noti -> TYPE_NOTI
41+
is ArticleMainState.Content -> TYPE_ARTICLE
42+
else -> throw IllegalArgumentException("Invalid type of data - $position")
43+
}
44+
}
45+
46+
override fun onCreateViewHolder(
47+
parent: ViewGroup,
48+
viewType: Int
49+
): RecyclerView.ViewHolder {
50+
return when (viewType) {
51+
TYPE_NOTI -> {
52+
KeywordNotiViewHolder(
53+
MainCardArticleNotiBinding.inflate(
54+
LayoutInflater.from(parent.context),
55+
parent,
56+
false
57+
)
58+
)
59+
}
60+
TYPE_ARTICLE -> {
61+
HotArticleViewHolder(
62+
MainCardArticleBinding.inflate(
63+
LayoutInflater.from(parent.context),
64+
parent,
65+
false
66+
)
67+
)
68+
}
69+
else -> throw IllegalArgumentException("Invalid type of view type $viewType")
70+
}
71+
}
72+
73+
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
74+
when (val item = getItem(position)) {
75+
is ArticleMainState.Noti -> (holder as KeywordNotiViewHolder).bind(item)
76+
is ArticleMainState.Content -> (holder as HotArticleViewHolder).bind(item)
77+
}
78+
}
79+
80+
companion object {
81+
const val TYPE_NOTI = 0
82+
const val TYPE_ARTICLE = 1
83+
84+
private val diffCallback = object : DiffUtil.ItemCallback<ArticleMainState>() {
85+
override fun areItemsTheSame(
86+
oldItem: ArticleMainState,
87+
newItem: ArticleMainState
88+
): Boolean {
89+
return when {
90+
oldItem is ArticleMainState.Noti && newItem is ArticleMainState.Noti -> oldItem.title == newItem.title
91+
oldItem is ArticleMainState.Content && newItem is ArticleMainState.Content -> oldItem.id == newItem.id
92+
else -> false
93+
}
94+
}
95+
96+
override fun areContentsTheSame(
97+
oldItem: ArticleMainState,
98+
newItem: ArticleMainState
99+
): Boolean {
100+
return oldItem == newItem
101+
}
102+
}
103+
}
104+
}

0 commit comments

Comments
 (0)