Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.flipcash.app.internal.analytics

import com.flipcash.app.analytics.TokenSymbolResolver
import com.flipcash.app.tokens.TokenCoordinator
import com.getcode.solana.keys.Mint
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* Binds analytics' mint→ticker lookup to the token cache.
*
* Lives in `:app` because it is the only module that depends on both
* `:shared:analytics` and `:shared:tokens`.
*/
@Module
@InstallIn(SingletonComponent::class)
object TokenSymbolResolverModule {

@Provides
@Singleton
fun providesTokenSymbolResolver(
tokenCoordinator: TokenCoordinator,
): TokenSymbolResolver = TokenSymbolResolver { mintBase58 ->
// Cache-only and synchronous: analytics must never block or fetch.
// An uncached mint yields null, and the property is omitted.
runCatching { Mint(mintBase58) }.getOrNull()
?.let { tokenCoordinator.cachedToken(it) }
?.symbol
?.takeIf { it.isNotBlank() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.navigation3.runtime.NavKey
import com.flipcash.app.android.R
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.navigation.DeeplinkAction
import com.flipcash.app.core.navigation.homeRoute
import com.flipcash.app.core.extensions.navigateAll
Expand Down Expand Up @@ -199,6 +200,7 @@ internal fun buildNavGraphForLaunch(
listOf(
AppRoute.UpdateUserProfile(
origin = AppRoute.OnboardingFlow(),
nameSource = DisplayNameSource.Onboarding,
includeName = true,
includePhoto = false,
target = AppRoute.OnboardingFlow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ sealed interface AppRoute : NavKey, Parcelable {
@Parcelize
data class UpdateUserProfile(
val origin: AppRoute,
val nameSource: DisplayNameSource,
val includeName: Boolean = true,
val includePhoto: Boolean = true,
val target: AppRoute? = null,
Expand Down Expand Up @@ -368,3 +369,7 @@ private fun buildUpdateUserProfileStack(
if (includeName) add(UpdateProfileStep.Name)
if (includePhoto) add(UpdateProfileStep.Photo)
}

/** Where a display-name entry flow was launched from. Reported as the `Source` analytics property. */
@Serializable
enum class DisplayNameSource { Onboarding, MyAccount, TipCardSetup }
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.navigation3.runtime.entryProvider
import com.flipcash.app.analytics.Action
import com.flipcash.app.analytics.Button
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.core.navigation.homeRoute
Expand Down Expand Up @@ -269,6 +270,7 @@ private fun FlowNavigator<OnboardingStep, OnboardingResult>.proceedToNameOrPermi
navigate(
AppRoute.UpdateUserProfile(
origin = AppRoute.OnboardingFlow(),
nameSource = DisplayNameSource.Onboarding,
includeName = true,
includePhoto = false,
target = AppRoute.OnboardingFlow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ internal class ChatViewModel @Inject constructor(

// A payment into a tip DM is a tip; a contact DM is a plain cash send.
val transferEvent = if (stateFlow.value.participant is ChatParticipant.TipUser) {
Analytics.Transfer.SentTip
Analytics.Transfer.SentTip(TipOrigin.CHAT)
} else {
Analytics.Transfer.SentCash
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreen
import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel
import com.flipcash.core.R
Expand Down Expand Up @@ -49,6 +50,7 @@ fun MyAccountScreen() {
navigator.push(
AppRoute.UpdateUserProfile(
origin = AppRoute.Menu.MyAccount,
nameSource = DisplayNameSource.MyAccount,
includeName = true,
includePhoto = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent
import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel
import com.flipcash.core.R
Expand Down Expand Up @@ -50,6 +51,7 @@ fun UserProfileScreen() {
navigator.push(
AppRoute.UpdateUserProfile(
origin = AppRoute.Menu.UserProfile,
nameSource = DisplayNameSource.MyAccount,
includeName = true,
includePhoto = false,
)
Expand All @@ -64,6 +66,7 @@ fun UserProfileScreen() {
navigator.push(
AppRoute.UpdateUserProfile(
origin = AppRoute.Menu.UserProfile,
nameSource = DisplayNameSource.MyAccount,
includeName = false,
includePhoto = true,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewWrapper
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.tipping.TipResult
import com.flipcash.app.core.tipping.TipStep
Expand Down Expand Up @@ -55,6 +56,7 @@ internal fun TipInfoScreen() {
flowNavigator.navigate(
AppRoute.UpdateUserProfile(
origin = AppRoute.Sheets.Tips(),
nameSource = DisplayNameSource.TipCardSetup,
includeName = userManager?.profile?.displayName.isNullOrEmpty(),
includePhoto = false, // explicity false for now
target = AppRoute.Sheets.Tips(resumed = true),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ private fun profileUpdateProvider(
route: AppRoute.UpdateUserProfile,
): (NavKey) -> NavEntry<NavKey> = entryProvider {
annotatedEntry<UpdateProfileStep.Name> {
NameEntryScreen(allowBack = route.allowBack)
NameEntryScreen(source = route.nameSource, allowBack = route.allowBack)
}
annotatedEntry<UpdateProfileStep.Photo> {
PhotoSelectionScreen()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.ui.DisplayTextInput
import com.flipcash.app.core.ui.transitions.SharedTransition
import com.flipcash.app.core.ui.transitions.sharedElementTransition
Expand All @@ -43,6 +44,7 @@ import kotlinx.coroutines.flow.onEach

@Composable
internal fun NameEntryScreen(
source: DisplayNameSource,
allowBack: Boolean = true,
) {
val flowNavigator = rememberFlowNavigator<UpdateProfileStep, UpdateProfileResult>()
Expand All @@ -67,7 +69,7 @@ internal fun NameEntryScreen(
} else {
Spacer(Modifier.statusBarsPadding().padding(2.5.dp))
}
NameEntryScreenContent(state, viewModel::dispatchEvent)
NameEntryScreenContent(state, source, viewModel::dispatchEvent)
}

LaunchedEffect(viewModel) {
Expand All @@ -81,6 +83,7 @@ internal fun NameEntryScreen(
@Composable
private fun NameEntryScreenContent(
state: NameEntryViewModel.State,
source: DisplayNameSource,
dispatchEvent: (NameEntryViewModel.Event) -> Unit,
) {
val keyboard = rememberKeyboardController()
Expand Down Expand Up @@ -115,7 +118,7 @@ private fun NameEntryScreenContent(
isSuccess = state.processingState.success,
onClick = {
keyboard.hideIfVisible {
dispatchEvent(NameEntryViewModel.Event.CheckName)
dispatchEvent(NameEntryViewModel.Event.CheckName(source))
}
},
)
Expand Down Expand Up @@ -143,7 +146,7 @@ private fun NameEntryScreenContent(
),
onKeyboardAction = {
keyboard.hideIfVisible {
dispatchEvent(NameEntryViewModel.Event.CheckName)
dispatchEvent(NameEntryViewModel.Event.CheckName(source))
}
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.flipcash.app.userprofile.internal.name
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.lifecycle.viewModelScope
import com.flipcash.app.analytics.FlipcashAnalyticsService
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.extensions.flatMapResult
import com.flipcash.app.core.extensions.onResult
import com.flipcash.features.userprofile.R
Expand Down Expand Up @@ -30,7 +32,8 @@ import kotlin.time.Duration.Companion.milliseconds

@HiltViewModel
class NameEntryViewModel @Inject constructor(
userManager: UserManager,
private val userManager: UserManager,
private val analytics: FlipcashAnalyticsService,
private val moderationController: ModerationController,
private val profileController: ProfileController,
private val resources: ResourceHelper,
Expand All @@ -48,7 +51,7 @@ class NameEntryViewModel @Inject constructor(
}

sealed interface Event {
data object CheckName : Event
data class CheckName(val source: DisplayNameSource) : Event
data class UpdateProcessingState(
val loading: Boolean = false,
val success: Boolean = false
Expand All @@ -68,10 +71,19 @@ class NameEntryViewModel @Inject constructor(

eventFlow
.filterIsInstance<Event.CheckName>()
.map { stateFlow.value.nameFieldState.text.toString() }
.onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) }
.map {
profileController.setDisplayName(stateFlow.value.nameFieldState.text.toString())
.map { event ->
// Read the prior name BEFORE the write. The profile flow above
// pushes the stored name into the field, so the field itself
// cannot tell us whether one already existed.
val hadPreviousName = !userManager.profile?.displayName.isNullOrBlank()
val result = profileController.setDisplayName(
stateFlow.value.nameFieldState.text.toString()
)
result.onSuccess {
analytics.displayNameSubmitted(event.source, hadPreviousName)
}
result
}.onResult(
onSuccess = {
viewModelScope.launch {
Expand Down Expand Up @@ -148,7 +160,7 @@ class NameEntryViewModel @Inject constructor(
companion object {
private val updateStateForEvent: (Event) -> (State.() -> State) = { event ->
when (event) {
Event.CheckName -> { state -> state }
is Event.CheckName -> { state -> state }
is Event.UpdateProcessingState -> { state ->
val current = state.processingState
state.copy(
Expand Down
3 changes: 3 additions & 0 deletions apps/flipcash/shared/analytics/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ android {
}

dependencies {
testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)

implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.bugsnag)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.flipcash.app.analytics

import androidx.compose.runtime.Composable
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.navigation.DeeplinkType
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
import com.flipcash.services.models.TipOrigin
import com.flipcash.services.models.chat.ChatType
import com.getcode.ed25519.Ed25519.KeyPair
import com.getcode.libs.analytics.AnalyticsService
Expand Down Expand Up @@ -44,13 +46,31 @@ interface FlipcashAnalyticsService : AnalyticsService {
fun deeplinkRouted(type: DeeplinkType, error: Throwable? = null)
fun displayedErrorModal(title: String, message: String, screen: String? = null, callSite: String? = null)

/** @param hadPreviousName true when the user is replacing a name, false on first set. */
fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean)

/** Increments a cumulative per-user counter. [amount] defaults to a single occurrence. */
fun incrementReceivedCounter(counter: Analytics.ReceivedCounter, amount: Double = 1.0)

fun tipReceived(chatType: ChatType, amount: Fiat, mint: Mint)
fun messageReceived(chatType: ChatType)

fun buttonTapped(button: Button) {
action(button)
}
}

object Analytics {

/**
* Cumulative per-user counters stored as Mixpanel people properties.
*
* These are incremented once per received message and are NOT idempotent —
* every caller must be behind the analytics watermark. See
* ChatMetadataDataSource.getAnalyticsCountedThrough.
*/
enum class ReceivedCounter { Tips, TipsValue, Messages }

sealed interface Transfer {
sealed interface Initiate: Transfer {
data object GrabBillStart: Initiate
Expand All @@ -67,7 +87,7 @@ object Analytics {
}

data object SentCash : Transfer
data object SentTip : Transfer
data class SentTip(val origin: TipOrigin) : Transfer
}
enum class OnrampSource { Settings, Balance, Give }
enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall, Chat, Scanner, Balance }
Expand Down Expand Up @@ -126,6 +146,10 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService {
override fun deeplinkParsed(type: DeeplinkType?, url: String) = Unit
override fun deeplinkRouted(type: DeeplinkType, error: Throwable?) = Unit
override fun displayedErrorModal(title: String, message: String, screen: String?, callSite: String?) = Unit
override fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean) = Unit
override fun incrementReceivedCounter(counter: Analytics.ReceivedCounter, amount: Double) = Unit
override fun tipReceived(chatType: ChatType, amount: Fiat, mint: Mint) = Unit
override fun messageReceived(chatType: ChatType) = Unit
}

@Composable
Expand Down
Loading
Loading