Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -288,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): 해당 피드만 원복, 나머지 동시 변경사항 보존
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