Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/distribute.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions core/analytics/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.sseotdabwa.buyornot.core.analytics

interface Analytics {
fun track(event: AnalyticsEvent)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.sseotdabwa.buyornot.core.analytics

sealed class AnalyticsEvent {
data class FeedViewed(
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()
Comment thread
DongChyeon marked this conversation as resolved.

data class VoteCreateAbandoned(
val filledFields: List<String>,
val lastStep: String?,
) : AnalyticsEvent()
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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<String, JSONObject> {
val props = JSONObject()
val name =
when (this) {
is AnalyticsEvent.FeedViewed -> {
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)
Comment thread
DongChyeon marked this conversation as resolved.
"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
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions feature/home/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ android {
}

dependencies {
implementation(projects.core.analytics)
implementation(projects.domain)
implementation(projects.core.common)
implementation(projects.core.designsystem)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, HomeIntent, HomeSideEffect>(HomeUiState()) {
private var currentUserId: Long? = null
private var isUserIdLoaded = false
Expand Down Expand Up @@ -133,6 +136,19 @@ 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(
firstVisibleItemIndex = intent.firstVisibleItemIndex,
),
)
is HomeIntent.OnFeedScreenExited ->
analytics.track(
AnalyticsEvent.FeedExited(
timeSpentSeconds = intent.timeSpentSeconds,
lastVisibleItemIndex = intent.lastVisibleItemIndex,
),
)
}
}

Expand Down Expand Up @@ -250,6 +266,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,
),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// 1. 낙관적 업데이트 (Optimistic Update)
updateState { state ->
val newAllFeeds = optimisticVoteUpdate(state.allFeeds, feedId, optionIndex)
Expand Down
1 change: 1 addition & 0 deletions feature/upload/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ android {
}

dependencies {
implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.domain)
implementation(projects.core.designsystem)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ data class UploadUiState(
val showExitDialog: Boolean = false,
val showPhotoPickerSheet: Boolean = false,
val categories: List<FeedCategory> = FeedCategory.entries,
val lastTouchedField: String? = null,
) {
val hasInput: Boolean
get() =
Expand All @@ -27,6 +28,17 @@ data class UploadUiState(
link.isNotEmpty() ||
title.isNotEmpty() ||
content.isNotEmpty()

val filledFields: List<String>
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 {
Expand Down
Loading
Loading