From 6f39d0ac68af604a3191588266009d52311123b8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 20:06:42 -0400 Subject: [PATCH] feat(analytics): send the Profile events The seven Profile events were catalogued but nothing sent them. Wire each builder at its screen or save point and cover each send, and each path that must stay silent, with RecordingAnalytics. --- apps/flipcash/features/menu/build.gradle.kts | 1 + .../app/menu/internal/ProfileCardViewModel.kt | 4 + .../menu/internal/ProfileCardViewModelTest.kt | 22 ++++ .../screens/profile/ChatProfileScreen.kt | 1 + .../screens/profile/ChatProfileViewModel.kt | 39 ++++++- .../profile/ChatProfileViewModelTest.kt | 110 ++++++++++++++++++ .../features/myaccount/build.gradle.kts | 1 + .../editprofile/EditProfileViewModel.kt | 2 + .../internal/settings/SettingsViewModel.kt | 4 + .../internal/SettingsViewModelTest.kt | 18 ++- .../editprofile/EditProfileViewModelTest.kt | 18 ++- .../internal/bio/EditBioViewModel.kt | 11 +- .../mintip/MinimumTipEntryViewModel.kt | 5 + .../internal/name/NameEntryViewModel.kt | 6 + .../internal/photo/PhotoSelectionViewModel.kt | 12 ++ .../username/UsernameEntryViewModel.kt | 7 ++ .../internal/bio/EditBioViewModelTest.kt | 64 ++++++++++ .../internal/name/NameEntryViewModelTest.kt | 50 +++++++- .../photo/PhotoSelectionViewModelTest.kt | 55 +++++++++ .../username/UsernameEntryViewModelTest.kt | 77 ++++++++++++ 20 files changed, 499 insertions(+), 8 deletions(-) create mode 100644 apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModelTest.kt diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index 713a985c05..37fba2fc34 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.bundles.haze) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:appupdates")) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:bills")) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt index a7f06f353a..662fe3312f 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt @@ -1,5 +1,7 @@ package com.flipcash.app.menu.internal +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -30,6 +32,7 @@ internal class ProfileCardViewModel @AssistedInject constructor( private val tipCodeExporter: TipCodeExporter, private val shareable: ShareSheetController, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, ) : ViewModel() { /** Null until the card resolves. */ @@ -37,6 +40,7 @@ internal class ProfileCardViewModel @AssistedInject constructor( private set init { + analytics.track(ProfileEvents.cardShown()) viewModelScope.launch { tippingCoordinator.profileCard(userId).onSuccess { card = it } } diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModelTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModelTest.kt index 957f777381..88a5ad2934 100644 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModelTest.kt +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModelTest.kt @@ -1,5 +1,8 @@ package com.flipcash.app.menu.internal +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.share.TipCodeExporter @@ -55,6 +58,7 @@ class ProfileCardViewModelTest { tipCodeExporter = exporter, shareable = mockk(relaxed = true), resources = resources, + analytics = RecordingAnalytics(), ) vm.download() BottomBarManager.messages.value.first().actions.first().onClick() @@ -83,4 +87,22 @@ class ProfileCardViewModelTest { fun `a blank name falls back to the generic name`() = runTest(mainCoroutineRule.dispatcher) { assertEquals("Profile Card", exportedBaseName(" ")) } + + @Test + fun `opening the card sends Card Shown even when the card fails to load`() = runTest(mainCoroutineRule.dispatcher) { + val tipping = mockk() + coEvery { tipping.profileCard(any()) } returns Result.failure(IllegalStateException("offline")) + val analytics = RecordingAnalytics() + + ProfileCardViewModel( + userId = listOf(1), + tippingCoordinator = tipping, + tipCodeExporter = exporter, + shareable = mockk(relaxed = true), + resources = resources, + analytics = analytics, + ) + + assertEquals(listOf(ProfileEvents.cardShown()), analytics.events) + } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt index 5d3548e9bf..eebdc248a5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileScreen.kt @@ -305,6 +305,7 @@ internal fun PersonProfileScreen( anchorHeight = menuAnchorHeight, onDismiss = { menuOpen = false }, onItem = { item -> + viewModel.dispatchEvent(ChatProfileViewModel.Event.MenuActionPicked(item)) when (item) { ChatProfileAction.Block -> viewModel.dispatchEvent(ChatProfileViewModel.Event.BlockUser) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt index 30cef25ed2..c1e5599b1e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModel.kt @@ -1,6 +1,10 @@ package com.flipcash.app.messenger.internal.screens.profile import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.ProfileMenuAction +import com.flipcash.analytics.ProfilePinnedAction as PinnedButton +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute @@ -69,6 +73,7 @@ internal class ChatProfileViewModel @Inject constructor( private val startChattingPayer: StartChattingPayer, private val chatController: ChatController, private val featuredGroupsStore: FeaturedGroupsStore, + private val analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(selfId = userManager.accountId), updateStateForEvent = updateStateForEvent, @@ -181,6 +186,9 @@ internal class ChatProfileViewModel @Inject constructor( /** The pinned button was tapped; what it does is [State.pinnedAction]'s. */ data object PinnedActionTapped : Event + + /** A row of the profile menu was tapped; the screen routes it separately. */ + data class MenuActionPicked(val action: ChatProfileAction) : Event data object Unblock : Event /** The slide on the fee sheet was confirmed. */ data object ConfirmStartChatting : Event @@ -380,14 +388,38 @@ internal class ChatProfileViewModel @Inject constructor( .onEach { val state = stateFlow.value when (val action = state.pinnedAction) { - ProfilePinnedAction.Unblock -> dispatchEvent(Event.Unblock) - ProfilePinnedAction.OpenChat -> state.dmChatId?.let { dispatchEvent(Event.OpenChat(it)) } - is ProfilePinnedAction.StartChatting -> startChatting() + ProfilePinnedAction.Unblock -> { + analytics.track(ProfileEvents.pinnedButtonTapped(PinnedButton.UNBLOCK)) + dispatchEvent(Event.Unblock) + } + ProfilePinnedAction.OpenChat -> { + analytics.track(ProfileEvents.pinnedButtonTapped(PinnedButton.OPEN_CHAT)) + state.dmChatId?.let { dispatchEvent(Event.OpenChat(it)) } + } + is ProfilePinnedAction.StartChatting -> { + analytics.track(ProfileEvents.pinnedButtonTapped(PinnedButton.START_CHATTING)) + startChatting() + } ProfilePinnedAction.OpeningChat, null -> Unit } } .launchIn(viewModelScope) + // Silence and Remove are group moderation, not in the Profile menu list, so they send nothing. + eventFlow + .filterIsInstance() + .mapNotNull { event -> + when (event.action) { + ChatProfileAction.Mute -> ProfileMenuAction.MUTE + ChatProfileAction.Report -> ProfileMenuAction.REPORT + ChatProfileAction.Block -> ProfileMenuAction.BLOCK + ChatProfileAction.Unblock -> ProfileMenuAction.UNBLOCK + ChatProfileAction.Silence, ChatProfileAction.Remove -> null + } + } + .onEach { analytics.track(ProfileEvents.menuAction(it)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .mapNotNull { (stateFlow.value.participant as? ChatParticipant.TipUser)?.userId } @@ -553,6 +585,7 @@ internal class ChatProfileViewModel @Inject constructor( is Event.BlockConfirmed, is Event.BlockSuccessful, Event.PinnedActionTapped, + is Event.MenuActionPicked, Event.Unblock, Event.ConfirmStartChatting, Event.PresentDepositOptions, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt index f6c7556835..3eb23749e6 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/ChatProfileViewModelTest.kt @@ -1,5 +1,10 @@ package com.flipcash.app.messenger.internal.screens.profile +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.ProfileMenuAction +import com.flipcash.analytics.ProfilePinnedAction as PinnedButton +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.blocklist.BlocklistCoordinator import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.MainCoroutineRule @@ -88,6 +93,8 @@ class ChatProfileViewModelTest { coEvery { mayProceed(any(), any()) } returns true } + private val analytics = RecordingAnalytics() + private fun viewModel() = ChatProfileViewModel( contactCoordinator = mockk(relaxed = true), userManager = userManager, @@ -102,6 +109,7 @@ class ChatProfileViewModelTest { startChattingPayer = payer, chatController = chatController, featuredGroupsStore = featuredGroupsStore, + analytics = analytics, ) private fun openFull(model: ChatProfileViewModel) = @@ -625,4 +633,106 @@ class ChatProfileViewModelTest { assertEquals(onBo, next) } + + // region analytics + + private fun pinnedTapSends(expected: PinnedButton) { + assertEquals(listOf(ProfileEvents.pinnedButtonTapped(expected)), analytics.events) + } + + @Test + fun `tapping start chatting sends Pinned Button Tapped`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + + pinnedTapSends(PinnedButton.START_CHATTING) + } + + @Test + fun `tapping open chat sends Pinned Button Tapped`() = runTest { + members.value = listOf(mockk()) + val model = viewModel() + openFull(model) + assertEquals(ProfilePinnedAction.OpenChat, model.stateFlow.value.pinnedAction) + + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + + pinnedTapSends(PinnedButton.OPEN_CHAT) + } + + @Test + fun `tapping the pinned unblock sends Pinned Button Tapped and no menu action`() = runTest { + blocked.value = true + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + + pinnedTapSends(PinnedButton.UNBLOCK) + } + + @Test + fun `tapping with no pinned button sends nothing`() = runTest { + val model = viewModel() + openFull(model) + assertNull(model.stateFlow.value.pinnedAction) + + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + + assertEquals(emptyList(), analytics.events) + } + + @Test + fun `tapping while the chat is opening sends nothing`() = runTest { + fee.value = Fiat(1, CurrencyCode.USD) + coEvery { payer.pay(any(), any(), any(), any()) } returns Result.success(dmChatId) + val model = viewModel() + openFull(model) + model.dispatchEvent(ChatProfileViewModel.Event.ConfirmStartChatting) + advanceTimeBy(1_000) + assertEquals(ProfilePinnedAction.OpeningChat, model.stateFlow.value.pinnedAction) + + model.dispatchEvent(ChatProfileViewModel.Event.PinnedActionTapped) + + assertEquals(emptyList(), analytics.events) + } + + @Test + fun `menu Mute, Report, Block and Unblock each send Menu Action`() = runTest { + val model = viewModel() + openFull(model) + + listOf( + ChatProfileAction.Mute, + ChatProfileAction.Report, + ChatProfileAction.Block, + ChatProfileAction.Unblock, + ).forEach { model.dispatchEvent(ChatProfileViewModel.Event.MenuActionPicked(it)) } + + assertEquals( + listOf( + ProfileEvents.menuAction(ProfileMenuAction.MUTE), + ProfileEvents.menuAction(ProfileMenuAction.REPORT), + ProfileEvents.menuAction(ProfileMenuAction.BLOCK), + ProfileEvents.menuAction(ProfileMenuAction.UNBLOCK), + ), + analytics.events, + ) + } + + @Test + fun `menu Silence and Remove send nothing`() = runTest { + val model = viewModel() + openFull(model) + + model.dispatchEvent(ChatProfileViewModel.Event.MenuActionPicked(ChatProfileAction.Silence)) + model.dispatchEvent(ChatProfileViewModel.Event.MenuActionPicked(ChatProfileAction.Remove)) + + assertEquals(emptyList(), analytics.events) + } + + // endregion } diff --git a/apps/flipcash/features/myaccount/build.gradle.kts b/apps/flipcash/features/myaccount/build.gradle.kts index 3362ff2d55..5dbccb453f 100644 --- a/apps/flipcash/features/myaccount/build.gradle.kts +++ b/apps/flipcash/features/myaccount/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.compose.paging) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:appsettings")) implementation(project(":apps:flipcash:shared:appupdates")) implementation(project(":apps:flipcash:shared:authentication")) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt index d026d7ce0f..a7058cfd8f 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModel.kt @@ -5,6 +5,7 @@ import com.flipcash.app.core.ui.forViewer import androidx.lifecycle.viewModelScope import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.analytics.events.ProfileEvents import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource @@ -93,6 +94,7 @@ internal class EditProfileViewModel @Inject constructor( } init { + analytics.track(ProfileEvents.editProfileViewed()) val profile = userManager.state .filter { it.authState is AuthState.Ready } .map { it.userProfile } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt index 4e031ce6e3..26ef7743f2 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -2,6 +2,8 @@ package com.flipcash.app.myaccount.internal.settings import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.auth.AuthManager @@ -49,6 +51,7 @@ internal class SettingsViewModel @Inject constructor( releaseStageProvider: ReleaseStageProvider, private val resources: ResourceHelper, private val authManager: AuthManager, + private val analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -119,6 +122,7 @@ internal class SettingsViewModel @Inject constructor( } init { + analytics.track(ProfileEvents.settingsViewed()) dispatchEvent(Event.OnAppVersionUpdated(versionInfo)) combine( diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt index 648ddce333..b8e6adf8d7 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt @@ -1,5 +1,9 @@ package com.flipcash.app.myaccount.internal +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.MainCoroutineRule @@ -168,7 +172,9 @@ class SettingsViewModelTest { private val featureFlags = mockk(relaxed = true) - private fun TestScope.createViewModel(): SettingsViewModel { + private fun TestScope.createViewModel( + analytics: FlipcashAnalytics = RecordingAnalytics(), + ): SettingsViewModel { val userFlags = mockk(relaxed = true) val resolved = mockk(relaxed = true) { every { isStaff } returns ResolvedFlag(serverValue = false, override = FieldOverride.None) @@ -189,6 +195,7 @@ class SettingsViewModelTest { releaseStageProvider = mockk(relaxed = true), resources = FakeResourceHelper(), authManager = mockk(relaxed = true), + analytics = analytics, dispatchers = TestDispatchers(testScheduler), ) } @@ -252,4 +259,13 @@ class SettingsViewModelTest { } // endregion + + @Test + fun `opening the screen sends Settings Viewed once`() = runTest(mainCoroutineRule.dispatcher) { + val analytics = RecordingAnalytics() + createViewModel(analytics) + advanceUntilIdle() + + assertEquals(listOf(ProfileEvents.settingsViewed()), analytics.events) + } } diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt index c8c77541e2..8ba128905c 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/editprofile/EditProfileViewModelTest.kt @@ -3,7 +3,10 @@ package com.flipcash.app.myaccount.internal.editprofile import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.exchange.Exchange import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.events.ProfileEvents import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers @@ -60,7 +63,9 @@ class EditProfileViewModelTest { BottomBarManager.clear() } - private fun TestScope.viewModel(): EditProfileViewModel { + private fun TestScope.viewModel( + analytics: FlipcashAnalytics = mockk(relaxed = true), + ): EditProfileViewModel { val userManager = mockk(relaxed = true) every { userManager.state } returns MutableStateFlow(UserManager.State()) val userFlags = mockk(relaxed = true) @@ -74,7 +79,7 @@ class EditProfileViewModelTest { tipPayments = mockk(relaxed = true), featuredGroups = featuredGroups, purchaseMethodController = purchaseMethodController, - analytics = mockk(relaxed = true), + analytics = analytics, resources = FakeResourceHelper(), exchange = mockk { every { preferredRate } returns Rate.oneToOne @@ -84,6 +89,15 @@ class EditProfileViewModelTest { ) } + @Test + fun `opening the screen sends Edit Profile Viewed once`() = runTest(mainCoroutineRule.dispatcher) { + val analytics = RecordingAnalytics() + viewModel(analytics) + advanceUntilIdle() + + assertEquals(listOf(ProfileEvents.editProfileViewed()), analytics.events) + } + private fun locked() = UsernameGate.Locked( minimum = mockk(relaxed = true), shortfall = mockk(relaxed = true), diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt index 5fea3b044d..69015142b9 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModel.kt @@ -1,5 +1,8 @@ package com.flipcash.app.userprofile.internal.bio +import com.flipcash.analytics.ProfileField +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshotFlow @@ -31,6 +34,7 @@ internal class EditBioViewModel @Inject constructor( userManager: UserManager, private val profileController: ProfileController, private val resources: ResourceHelper, + private val analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -83,6 +87,7 @@ internal class EditBioViewModel @Inject constructor( dispatchEvent(Event.UpdateProcessingState(loading = true)) profileController.setBio(draft.text.trim()) .onSuccess { + analytics.track(ProfileEvents.fieldSaved(ProfileField.BIO)) viewModelScope.launch { dispatchEvent(Event.UpdateProcessingState(success = true)) delay(500.milliseconds) @@ -100,7 +105,11 @@ internal class EditBioViewModel @Inject constructor( // The text is kept in every case so the user can reword it rather than retype it. private fun handleSaveFailure(cause: Throwable) { when (cause) { - is SetBioError.FailedModerated -> dispatchEvent(Event.OnFailed(BioError.Moderated)) + is SetBioError.FailedModerated -> { + analytics.track(ProfileEvents.bioModerationFailed()) + dispatchEvent(Event.OnFailed(BioError.Moderated)) + } + is SetBioError.InvalidBio, is ValidationException -> dispatchEvent(Event.OnFailed(BioError.Invalid)) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt index 49a057dfe5..1a8a2ddc05 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/mintip/MinimumTipEntryViewModel.kt @@ -1,5 +1,8 @@ package com.flipcash.app.userprofile.internal.mintip +import com.flipcash.analytics.ProfileField +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics import androidx.lifecycle.viewModelScope import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.core.R @@ -50,6 +53,7 @@ internal class MinimumTipEntryViewModel @Inject constructor( private val profileController: ProfileController, userManager: UserManager, tipPaymentDelegate: TipPaymentDelegate, + private val analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -183,6 +187,7 @@ internal class MinimumTipEntryViewModel @Inject constructor( dispatchEvent(Event.UpdateSavingState(loading = true)) profileController.setMinDmChatInitFee(event.amount) .onSuccess { + analytics.track(ProfileEvents.fieldSaved(ProfileField.MINIMUM)) viewModelScope.launch { dispatchEvent(Event.UpdateSavingState(success = true)) delay(SuccessHoldDuration) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt index 29ab79a4e7..470f60ec28 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt @@ -1,5 +1,7 @@ package com.flipcash.app.userprofile.internal.name +import com.flipcash.analytics.ProfileField +import com.flipcash.analytics.events.ProfileEvents import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.viewModelScope @@ -169,6 +171,10 @@ class NameEntryViewModel @Inject constructor( DisplayNameEvents.set(event.source.analytics) } analytics.track(analyticsEvent) + // Onboarding and tip-card setup are already counted by Display Name: Set. + if (event.source == DisplayNameSource.MyAccount) { + analytics.track(ProfileEvents.fieldSaved(ProfileField.NAME)) + } } result }.onResult( diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt index 0b4f1d3ed4..b1beaef410 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModel.kt @@ -1,5 +1,8 @@ package com.flipcash.app.userprofile.internal.photo +import com.flipcash.analytics.ProfileField +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics import android.net.Uri import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope @@ -64,6 +67,7 @@ class PhotoSelectionViewModel @AssistedInject constructor( private val imagePreparer: ImageUploadPreparer, private val resources: ResourceHelper, val contentReader: ContentReader, + private val analytics: FlipcashAnalytics, @Assisted val slot: Slot, ) : BaseViewModel( initialState = State( @@ -225,6 +229,14 @@ class PhotoSelectionViewModel @AssistedInject constructor( } .onResult( onSuccess = { _ -> + analytics.track( + ProfileEvents.fieldSaved( + when (slot) { + Slot.Avatar -> ProfileField.PHOTO + Slot.Cover -> ProfileField.COVER + } + ) + ) viewModelScope.launch { dispatchEvent(Event.UpdateProcessingState(success = true)) delay(500.milliseconds) diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt index 9f9149844e..a25dbdb9c7 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModel.kt @@ -1,5 +1,8 @@ package com.flipcash.app.userprofile.internal.username +import com.flipcash.analytics.ProfileField +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.getcode.opencode.exchange.Exchange import com.flipcash.app.core.ui.forViewer import androidx.compose.foundation.text.input.TextFieldState @@ -52,6 +55,7 @@ class UsernameEntryViewModel @Inject constructor( private val userFlags: UserFlagsCoordinator, private val resources: ResourceHelper, private val exchange: Exchange, + private val analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent @@ -158,6 +162,9 @@ class UsernameEntryViewModel @Inject constructor( .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } .map { stateFlow.value.usernameFieldState.text.toString() } .map { username -> profileController.setUsername(username) } + .onEach { result -> + result.onSuccess { analytics.track(ProfileEvents.fieldSaved(ProfileField.USERNAME)) } + } .onResult( onSuccess = { viewModelScope.launch { diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt index 4381586288..35adb7122e 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/bio/EditBioViewModelTest.kt @@ -1,5 +1,9 @@ package com.flipcash.app.userprofile.internal.bio +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.ProfileField +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.services.controllers.ProfileController @@ -36,6 +40,7 @@ class EditBioViewModelTest { private val userManager = mockk(relaxed = true) private val profileController = mock() + private val analytics = RecordingAnalytics() private fun profile(bio: String) = UserProfile.Empty.copy(bio = bio) @@ -45,6 +50,7 @@ class EditBioViewModelTest { userManager = userManager, profileController = profileController, resources = FakeResourceHelper(), + analytics = analytics, dispatchers = TestDispatchers(testScheduler), ) } @@ -161,4 +167,62 @@ class EditBioViewModelTest { verify(profileController).setBio("") } + + @Test + fun `a saved bio sends Profile Field Saved for the bio`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn(Result.success(Unit)) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "New bio") } + advanceUntilIdle() + + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + assertEquals(listOf(ProfileEvents.fieldSaved(ProfileField.BIO)), analytics.events) + } + + @Test + fun `a moderated bio sends only Bio Moderation Failed`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn( + Result.failure(SetBioError.FailedModerated(ModerationResult.FlaggedCategory.OTHER)) + ) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "Something rude") } + advanceUntilIdle() + + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + assertEquals(listOf(ProfileEvents.bioModerationFailed()), analytics.events) + } + + @Test + fun `an invalid bio sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn(Result.failure(SetBioError.InvalidBio())) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "x") } + advanceUntilIdle() + + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + assertEquals(emptyList(), analytics.events) + } + + @Test + fun `a generic bio save failure sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setBio(any())).thenReturn(Result.failure(IllegalStateException("boom"))) + val vm = viewModel("Hello") + advanceUntilIdle() + vm.type { replace(0, length, "x") } + advanceUntilIdle() + + vm.dispatchEvent(EditBioViewModel.Event.Save) + advanceUntilIdle() + + assertEquals(emptyList(), analytics.events) + } } diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt index 0702286d28..ab96df56dc 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt @@ -2,7 +2,9 @@ package com.flipcash.app.userprofile.internal.name import com.flipcash.analytics.AnalyticsEvent import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.ProfileField import com.flipcash.analytics.events.DisplayNameEvents +import com.flipcash.analytics.events.ProfileEvents import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.analytics.analytics import com.flipcash.app.core.DisplayNameSource @@ -96,9 +98,55 @@ class NameEntryViewModelTest { AnalyticsEvent( "Display Name Updated", mapOf("Source" to PropertyValue.Text("My Account")), - ) + ), + ProfileEvents.fieldSaved(ProfileField.NAME), ), analytics.events, ) } + + @Test + fun `a name saved from My Account also sends Profile Field Saved`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + every { userManager.profile } returns null + whenever(profileController.setDisplayName(any())).thenReturn(Result.success(null)) + + createViewModel().dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.MyAccount)) + advanceUntilIdle() + + assertEquals( + listOf( + DisplayNameEvents.set(DisplayNameSource.MyAccount.analytics), + ProfileEvents.fieldSaved(ProfileField.NAME), + ), + analytics.events, + ) + } + + @Test + fun `a name saved from tip card setup sends no Profile Field Saved`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + every { userManager.profile } returns null + whenever(profileController.setDisplayName(any())).thenReturn(Result.success(null)) + + createViewModel().dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.TipCardSetup)) + advanceUntilIdle() + + assertEquals(listOf(DisplayNameEvents.set(DisplayNameSource.TipCardSetup.analytics)), analytics.events) + } + + @Test + fun `a failed save from My Account sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + every { userManager.profile } returns null + whenever(profileController.setDisplayName(any())).thenReturn(Result.failure(IllegalStateException("nope"))) + + createViewModel().dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.MyAccount)) + advanceUntilIdle() + + assertEquals(emptyList(), analytics.events) + } } diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt index b11b729b67..340d25a863 100644 --- a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/photo/PhotoSelectionViewModelTest.kt @@ -89,6 +89,8 @@ class PhotoSelectionViewModelTest { unmockkStatic(MimeTypeMap::class) } + private val analytics = com.flipcash.app.analytics.RecordingAnalytics() + private fun TestScope.viewModel(slot: PhotoSelectionViewModel.Slot): PhotoSelectionViewModel { every { userManager.profile } returns UserProfile.Empty.copy( profilePicture = avatar, @@ -108,6 +110,7 @@ class PhotoSelectionViewModelTest { imagePreparer = imagePreparer, resources = FakeResourceHelper(), contentReader = contentReader, + analytics = analytics, slot = slot, ) } @@ -262,4 +265,56 @@ class PhotoSelectionViewModelTest { failUploadWith(PhotoSelectionViewModel.Slot.Avatar, rejection(RejectionReason.UNKNOWN)), ) } + + @Test + fun `a saved avatar sends Profile Field Saved for the photo`() = runTest(mainCoroutineRule.dispatcher) { + stubUpload() + whenever(profileController.setProfilePicture(any())).thenReturn(Result.success(avatar)) + val vm = viewModel(PhotoSelectionViewModel.Slot.Avatar) + advanceUntilIdle() + + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + advanceUntilIdle() + + assertEquals( + listOf( + com.flipcash.analytics.events.ProfileEvents.fieldSaved(com.flipcash.analytics.ProfileField.PHOTO) + ), + analytics.events, + ) + } + + @Test + fun `a saved cover sends Profile Field Saved for the cover`() = runTest(mainCoroutineRule.dispatcher) { + stubUpload() + whenever(profileController.setCoverPicture(any())).thenReturn(Result.success(cover)) + val vm = viewModel(PhotoSelectionViewModel.Slot.Cover) + advanceUntilIdle() + + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + advanceUntilIdle() + + assertEquals( + listOf( + com.flipcash.analytics.events.ProfileEvents.fieldSaved(com.flipcash.analytics.ProfileField.COVER) + ), + analytics.events, + ) + } + + @Test + fun `a rejected cover sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + stubUpload() + whenever(profileController.setCoverPicture(any())).thenReturn(Result.failure(SetCoverPictureError.BlobRejected())) + val vm = viewModel(PhotoSelectionViewModel.Slot.Cover) + advanceUntilIdle() + + vm.dispatchEvent(PhotoSelectionViewModel.Event.OnImageCached(pick, "image/jpeg")) + vm.dispatchEvent(PhotoSelectionViewModel.Event.CheckImage) + advanceUntilIdle() + + assertEquals(emptyList(), analytics.events) + } } diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModelTest.kt new file mode 100644 index 0000000000..a00161f133 --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/username/UsernameEntryViewModelTest.kt @@ -0,0 +1,77 @@ +package com.flipcash.app.userprofile.internal.username + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.ProfileField +import com.flipcash.analytics.events.ProfileEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.user.UserManager +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class UsernameEntryViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val analytics = RecordingAnalytics() + // Mockito for the Result-returning suspend method (MockK double-boxes Result inline class) + private val profileController = mock() + + private fun createViewModel(): UsernameEntryViewModel { + val userManager = mockk(relaxed = true) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + return UsernameEntryViewModel( + userManager = userManager, + profileController = profileController, + userFlags = mockk(relaxed = true), + resources = FakeResourceHelper(), + exchange = mockk(relaxed = true), + analytics = analytics, + ) + } + + // The base ViewModel dispatches events on Dispatchers.Default, so the test scheduler can't + // drain them; wait in real time instead. + private suspend fun awaitRealTime(condition: () -> Boolean) = withContext(Dispatchers.Default) { + withTimeout(5_000) { while (!condition()) delay(10) } + } + + @Test + fun `a claimed username sends Profile Field Saved for the username`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setUsername(any())).thenReturn(Result.success(Unit)) + + createViewModel().dispatchEvent(UsernameEntryViewModel.Event.CheckUsername) + awaitRealTime { analytics.events.isNotEmpty() } + + assertEquals(listOf(ProfileEvents.fieldSaved(ProfileField.USERNAME)), analytics.events) + } + + @Test + fun `a failed username claim sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + whenever(profileController.setUsername(any())).thenReturn(Result.failure(IllegalStateException("boom"))) + + createViewModel().dispatchEvent(UsernameEntryViewModel.Event.CheckUsername) + // No event to wait for on the failure path; give the pipeline time to run to its end. + withContext(Dispatchers.Default) { delay(500) } + + assertEquals(emptyList(), analytics.events) + } +}