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) + } +}