From 23ea68e5847018b6d4dcf0029dd2d9172ed629bb Mon Sep 17 00:00:00 2001 From: Leigh Douglas Date: Tue, 3 Sep 2024 16:34:24 -0400 Subject: [PATCH] MBL-1665 & 1662: Project Alerts red dot indicators on overflow menu (#2113) * pass theme to logged in viewmodel, set indicators on hamburger icon and project alert menu item when users has project alerts available * pass theme to logged in viewmodel, set indicators on hamburger icon and project alert menu item when users has project alerts available * Add tests, cleanup * linter * fix schema after merge --------- Co-authored-by: Leigh Douglas Co-authored-by: Isabel Martin Co-authored-by: mtgriego --- app/src/main/graphql/schema.graphqls | 33 ++++++++++----- .../main/java/com/kickstarter/models/User.kt | 7 ++++ .../discoverydrawer/LoggedInViewHolder.kt | 24 ++++++++++- .../viewmodels/DiscoveryViewModel.kt | 14 +++++-- .../viewmodels/LoggedInViewHolderViewModel.kt | 8 ++++ app/src/main/res/drawable/circle_red_05.xml | 10 +++++ app/src/main/res/drawable/circle_red_06.xml | 10 +++++ .../discovery_drawer_logged_in_view.xml | 33 ++++++++++++--- .../viewmodels/DiscoveryViewModelTest.kt | 41 ++++++++++++++++++- .../LoggedInViewHolderViewModelTest.kt | 20 +++++++++ 10 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 app/src/main/res/drawable/circle_red_05.xml create mode 100644 app/src/main/res/drawable/circle_red_06.xml diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index 126c11a8d1..0a9026d374 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -385,6 +385,10 @@ type User implements Node { """ organizations("""Returns the first _n_ elements from the list.""" first: Int, """Returns the elements in the list that come after the specified cursor.""" after: String, """Returns the last _n_ elements from the list.""" last: Int, """Returns the elements in the list that come before the specified cursor.""" before: String, """Filter organizations by membership state.""" state: OrganizationMembershipState): UserOrganizationsConnection """ + Whether backer has any action in PPO + """ + ppoHasAction: Boolean + """ A user's project notification settings """ projectNotifications: [Notification!] @@ -532,8 +536,6 @@ enum Feature { late_pledges_learn_more_cta post_campaign_backings_2024 ckeditor_project_updates - project_card_unification_2023 - project_card_unification_2023_videos backer_discovery_features_2023 payments_stripe_link_on_checkout address_collection_2024 @@ -550,6 +552,7 @@ enum Feature { prelaunch_story_editor prelaunch_story_exception creator_nav_refresh + pledge_redemption_cross_sells } """ @@ -1372,7 +1375,7 @@ type Project implements Node & Commentable { """ targetLaunchDateUpdatedAt: ISO8601DateTime """ - Tax categories configured for the project + Tax categories configured for the project excluding the non-taxable category """ taxCategories: [TaxCategory!]! """ @@ -6595,6 +6598,10 @@ type Order implements Node { """ backing: Backing! """ + The currency of the order + """ + currency: CurrencyCode! + """ The funds capture key """ fundsCaptureKey: String @@ -6602,7 +6609,7 @@ type Order implements Node { """ The cost of tax on reward items """ - itemTax: Money + itemTax: Int """ The project associated with the order """ @@ -6610,11 +6617,11 @@ type Order implements Node { """ The cost of shipping """ - shippingAmount: Money + shippingAmount: Int """ The cost of tax on shipping """ - shippingTax: Money + shippingTax: Int """ The order's state, e.g. draft, submitted, successful, errored, missed """ @@ -6622,11 +6629,15 @@ type Order implements Node { """ The total cost for the order including taxes and shipping """ - total: Money + total: Int """ The total tax amount for the order """ - totalTax: Money + totalTax: Int + """ + The amount pledged during the crowdfunding campaign + """ + voucherAmount: Int! } """ @@ -12658,9 +12669,9 @@ type CreateOrUpdateItemTaxConfigPayload { """ clientMutationId: String """ - The created or updated item tax config + Success if item tax config was created or updated successfully """ - itemTaxConfig: ItemTaxConfig! + success: Boolean! } """ @@ -13136,7 +13147,7 @@ Shipping rule for a reward """ input ShippingRateInput { id: ID - cost: Int! + cost: Int locationId: String! } diff --git a/app/src/main/java/com/kickstarter/models/User.kt b/app/src/main/java/com/kickstarter/models/User.kt index d72884264a..11ff4c3070 100644 --- a/app/src/main/java/com/kickstarter/models/User.kt +++ b/app/src/main/java/com/kickstarter/models/User.kt @@ -50,6 +50,7 @@ class User private constructor( private val notifyOfMessages: Boolean, private val notifyOfUpdates: Boolean, private val optedOutOfRecommendations: Boolean, + private val ppoHasAction: Boolean?, private val promoNewsletter: Boolean, private val publishingNewsletter: Boolean, private val showPublicProfile: Boolean, @@ -105,6 +106,7 @@ class User private constructor( fun notifyOfMessages() = this.notifyOfMessages fun notifyOfUpdates() = this.notifyOfUpdates fun optedOutOfRecommendations() = this.optedOutOfRecommendations + fun ppoHasAction() = this.ppoHasAction fun promoNewsletter() = this.promoNewsletter fun publishingNewsletter() = this.publishingNewsletter fun showPublicProfile() = this.showPublicProfile @@ -161,6 +163,7 @@ class User private constructor( private var notifyOfUpdates: Boolean = false, private var optedOutOfRecommendations: Boolean = false, private var promoNewsletter: Boolean = false, + private var ppoHasAction: Boolean? = false, private var publishingNewsletter: Boolean = false, private var showPublicProfile: Boolean = false, private var needsPassword: Boolean? = false, @@ -214,6 +217,7 @@ class User private constructor( fun notifyOfMessages(notifyOfMessages: Boolean?) = apply { this.notifyOfMessages = notifyOfMessages ?: false } fun notifyOfUpdates(notifyOfUpdates: Boolean?) = apply { this.notifyOfUpdates = notifyOfUpdates ?: false } fun optedOutOfRecommendations(optedOutOfRecommendations: Boolean?) = apply { this.optedOutOfRecommendations = optedOutOfRecommendations ?: false } + fun ppoHasAction(ppoHasAction: Boolean?) = apply { this.ppoHasAction = ppoHasAction ?: false } fun promoNewsletter(promoNewsletter: Boolean?) = apply { this.promoNewsletter = promoNewsletter ?: false } fun publishingNewsletter(publishingNewsletter: Boolean?) = apply { this.publishingNewsletter = publishingNewsletter ?: false } fun showPublicProfile(showPublicProfile: Boolean?) = apply { this.showPublicProfile = showPublicProfile ?: false } @@ -268,6 +272,7 @@ class User private constructor( notifyOfMessages = notifyOfMessages, notifyOfUpdates = notifyOfUpdates, optedOutOfRecommendations = optedOutOfRecommendations, + ppoHasAction = ppoHasAction, promoNewsletter = promoNewsletter, publishingNewsletter = publishingNewsletter, showPublicProfile = showPublicProfile, @@ -333,6 +338,7 @@ class User private constructor( notifyOfMessages = notifyOfMessages, notifyOfUpdates = notifyOfUpdates, optedOutOfRecommendations = optedOutOfRecommendations, + ppoHasAction = ppoHasAction, promoNewsletter = promoNewsletter, publishingNewsletter = publishingNewsletter, showPublicProfile = showPublicProfile, @@ -403,6 +409,7 @@ class User private constructor( notifyOfFriendActivity() == obj.notifyOfFriendActivity() && notifyOfMessages() == obj.notifyOfMessages() && optedOutOfRecommendations() == obj.optedOutOfRecommendations() && + ppoHasAction() == obj.ppoHasAction() && promoNewsletter() == obj.promoNewsletter() && publishingNewsletter() == obj.publishingNewsletter() && showPublicProfile() == obj.showPublicProfile() && diff --git a/app/src/main/java/com/kickstarter/ui/viewholders/discoverydrawer/LoggedInViewHolder.kt b/app/src/main/java/com/kickstarter/ui/viewholders/discoverydrawer/LoggedInViewHolder.kt index bad7597ed8..d977e242fb 100644 --- a/app/src/main/java/com/kickstarter/ui/viewholders/discoverydrawer/LoggedInViewHolder.kt +++ b/app/src/main/java/com/kickstarter/ui/viewholders/discoverydrawer/LoggedInViewHolder.kt @@ -1,7 +1,12 @@ package com.kickstarter.ui.viewholders.discoverydrawer +import android.graphics.drawable.Drawable +import android.util.Pair +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat +import com.kickstarter.R import com.kickstarter.databinding.DiscoveryDrawerLoggedInViewBinding +import com.kickstarter.libs.rx.transformers.Transformers.combineLatestPair import com.kickstarter.libs.utils.NumberUtils import com.kickstarter.libs.utils.extensions.addToDisposable import com.kickstarter.libs.utils.extensions.isNullOrZero @@ -10,6 +15,7 @@ import com.kickstarter.models.User import com.kickstarter.ui.extensions.loadCircleImage import com.kickstarter.ui.viewholders.KSViewHolder import com.kickstarter.viewmodels.LoggedInViewHolderViewModel +import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable @@ -27,6 +33,7 @@ class LoggedInViewHolder( fun loggedInViewHolderProfileClick(viewHolder: LoggedInViewHolder, user: User) fun loggedInViewHolderSettingsClick(viewHolder: LoggedInViewHolder, user: User) fun loggedInViewHolderPledgedProjectsClick(viewHolder: LoggedInViewHolder) + fun darkThemeEnabled(): Observable } init { @@ -62,7 +69,16 @@ class LoggedInViewHolder( this.viewModel.outputs.pledgedProjectsIsVisible() .observeOn(AndroidSchedulers.mainThread()) - .subscribe { binding.pledgedProjectsOverview.visibility = it.toVisibility() } + .subscribe { binding.drawerProjectAlerts.visibility = it.toVisibility() } + .addToDisposable(disposables) + + this.viewModel.outputs.pledgedProjectsIndicatorIsVisible() + .compose>(combineLatestPair(delegate.darkThemeEnabled())) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + binding.projectAlertsIndicator.setImageDrawable(selectProjectAlertIndicatorColor(it.second)) + binding.projectAlertsIndicator.visibility = it.first.toVisibility() + } .addToDisposable(disposables) this.viewModel.outputs.activityCountTextColor() @@ -75,7 +91,7 @@ class LoggedInViewHolder( binding.drawerSettings.setOnClickListener { this.delegate.loggedInViewHolderSettingsClick(this, user) } binding.drawerProfile.setOnClickListener { this.delegate.loggedInViewHolderProfileClick(this, user) } binding.userContainer.setOnClickListener { this.delegate.loggedInViewHolderProfileClick(this, user) } - binding.pledgedProjectsOverview.setOnClickListener { this.delegate.loggedInViewHolderPledgedProjectsClick(this) } + binding.drawerProjectAlerts.setOnClickListener { this.delegate.loggedInViewHolderPledgedProjectsClick(this) } }.addToDisposable(disposables) binding.drawerActivity.setOnClickListener { this.delegate.loggedInViewHolderActivityClick(this) } @@ -83,6 +99,10 @@ class LoggedInViewHolder( binding.internalTools.internalTools.setOnClickListener { this.delegate.loggedInViewHolderInternalToolsClick(this) } } + private fun selectProjectAlertIndicatorColor(isDarkMode: Boolean): Drawable? { + return if (isDarkMode) AppCompatResources.getDrawable(context(), R.drawable.circle_red_05) else AppCompatResources.getDrawable(context(), R.drawable.circle_red_06) + } + @Throws(Exception::class) override fun bindData(data: Any?) { this.viewModel.inputs.configureWith(requireNotNull(data as User)) diff --git a/app/src/main/java/com/kickstarter/viewmodels/DiscoveryViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/DiscoveryViewModel.kt index e8eefb0eb5..0428900268 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/DiscoveryViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/DiscoveryViewModel.kt @@ -131,12 +131,18 @@ interface DiscoveryViewModel { val erroredBackingsCount = user?.erroredBackingsCount().intValueOrZero() val unreadMessagesCount = user?.unreadMessagesCount().intValueOrZero() val unseenActivityCount = user?.unseenActivityCount().intValueOrZero() + + val ppoHasActions = when (user?.ppoHasAction()) { + true -> 1 + false, null -> 0 + } + return when { - erroredBackingsCount.isNonZero() -> { + (erroredBackingsCount.isNonZero() || ppoHasActions.isNonZero()) -> { if (isDarkTheme) R.drawable.ic_menu_error_indicator_dark else R.drawable.ic_menu_error_indicator } - (unreadMessagesCount + unseenActivityCount + erroredBackingsCount).isNonZero() -> { + (unreadMessagesCount + unseenActivityCount + erroredBackingsCount + ppoHasActions).isNonZero() -> { if (isDarkTheme) R.drawable.ic_menu_indicator_dark else R.drawable.ic_menu_indicator } @@ -179,6 +185,7 @@ interface DiscoveryViewModel { private val updateToolbarWithParams = BehaviorSubject.create() private val successMessage = PublishSubject.create() private val messageError = PublishSubject.create() + private val darkThemeEnabled = io.reactivex.subjects.BehaviorSubject.create() private var isDarkTheme = false private var isDarkThemeInitialized = false @@ -408,7 +415,6 @@ interface DiscoveryViewModel { currentUser .map { currentDrawerMenuIcon(it) } .distinctUntilChanged() - .compose(bindToLifecycle()) .subscribe { if (isDarkThemeInitialized) drawerMenuIcon.onNext(it) } } @@ -462,10 +468,12 @@ interface DiscoveryViewModel { override fun showErrorMessage(): Observable { return messageError } override fun showNotifPermissionsRequest(): Observable { return showNotifPermissionRequest } override fun showConsentManagementDialog(): Observable { return showConsentManagementDialog } + override fun darkThemeEnabled(): io.reactivex.Observable { return darkThemeEnabled } fun setDarkTheme(isDarkTheme: Boolean) { this.isDarkTheme = isDarkTheme this.isDarkThemeInitialized = true + darkThemeEnabled.onNext(isDarkTheme) } } } diff --git a/app/src/main/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModel.kt index 2d98d1c0bc..4878303e53 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModel.kt @@ -45,6 +45,7 @@ interface LoggedInViewHolderViewModel { /** Emits the user to pass to delegate. */ fun user(): Observable fun pledgedProjectsIsVisible(): Observable + fun pledgedProjectsIndicatorIsVisible(): Observable } class ViewModel(val environment: Environment) : Inputs, Outputs { @@ -59,6 +60,7 @@ interface LoggedInViewHolderViewModel { private val unreadMessagesCount = BehaviorSubject.create() private val userOutput = BehaviorSubject.create() private val pledgedProjectsIsVisible = BehaviorSubject.create() + private val pledgedProjectsIndicatorIsVisible = BehaviorSubject.create() private val disposables = CompositeDisposable() val inputs: Inputs = this @@ -104,6 +106,11 @@ interface LoggedInViewHolderViewModel { .subscribe { this.dashboardRowIsGone.onNext(it) } .addToDisposable(disposables) + this.user + .map { it.ppoHasAction().isTrue() } + .subscribe { this.pledgedProjectsIndicatorIsVisible.onNext(it) } + .addToDisposable(disposables) + Observable.just( environment.featureFlagClient() ?.getBoolean(FlagKey.ANDROID_PLEDGED_PROJECTS_OVERVIEW) ?: false @@ -135,5 +142,6 @@ interface LoggedInViewHolderViewModel { override fun user(): Observable = this.userOutput override fun pledgedProjectsIsVisible(): Observable = this.pledgedProjectsIsVisible + override fun pledgedProjectsIndicatorIsVisible(): Observable = this.pledgedProjectsIndicatorIsVisible } } diff --git a/app/src/main/res/drawable/circle_red_05.xml b/app/src/main/res/drawable/circle_red_05.xml new file mode 100644 index 0000000000..6d071d4489 --- /dev/null +++ b/app/src/main/res/drawable/circle_red_05.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/circle_red_06.xml b/app/src/main/res/drawable/circle_red_06.xml new file mode 100644 index 0000000000..431dca1ba4 --- /dev/null +++ b/app/src/main/res/drawable/circle_red_06.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/layout/discovery_drawer_logged_in_view.xml b/app/src/main/res/layout/discovery_drawer_logged_in_view.xml index 4eb32338e9..1801590459 100644 --- a/app/src/main/res/layout/discovery_drawer_logged_in_view.xml +++ b/app/src/main/res/layout/discovery_drawer_logged_in_view.xml @@ -81,14 +81,35 @@ - + android:contentDescription="@string/tabbar_activity" + android:orientation="horizontal" + android:visibility="gone"> + + + + + + () private val showNotifPermissionRequest = TestSubscriber() private val showConsentManagementDialog = TestSubscriber() - + private val darkThemeEnabled = io.reactivex.subscribers.TestSubscriber() + private val disposables = CompositeDisposable() private fun setUpEnvironment(environment: Environment) { vm = DiscoveryViewModel.ViewModel(environment) } + @Test + fun `test Dark Mode disabled`() { + val currentUser = MockCurrentUser() + val env = environment().toBuilder().currentUser(currentUser).build() + setUpEnvironment(env) + + vm.intent(Intent(Intent.ACTION_MAIN)) + + vm.outputs.darkThemeEnabled().subscribe { darkThemeEnabled.onNext(it) }.addToDisposable(disposables) + + vm.setDarkTheme(isDarkTheme = false) + + darkThemeEnabled.assertValues(false) + } + + @Test + fun `test Dark Mode enabled`() { + val currentUser = MockCurrentUser() + val env = environment().toBuilder().currentUser(currentUser).build() + setUpEnvironment(env) + + vm.intent(Intent(Intent.ACTION_MAIN)) + + vm.outputs.darkThemeEnabled().subscribe { darkThemeEnabled.onNext(it) }.addToDisposable(disposables) + + vm.setDarkTheme(isDarkTheme = true) + + darkThemeEnabled.assertValues(true) + } + @Test fun testDrawerData() { val currentUser = MockCurrentUser() @@ -808,4 +842,9 @@ class DiscoveryViewModelTest : KSRobolectricTestCase() { 0 ) } + + @After + fun cleanUp() { + disposables.clear() + } } diff --git a/app/src/test/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModelTest.kt index ea9b246e92..c76f747f70 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/LoggedInViewHolderViewModelTest.kt @@ -23,6 +23,7 @@ class LoggedInViewHolderViewModelTest : KSRobolectricTestCase() { private val unreadMessagesCount = TestSubscriber() private val user = TestSubscriber() private val pledgedProjectsIsVisible = TestSubscriber() + private val pledgedProjectsIndicatorIsVisible = TestSubscriber() private val disposables = CompositeDisposable() fun setUpEnvironment(environment: Environment) { @@ -35,6 +36,7 @@ class LoggedInViewHolderViewModelTest : KSRobolectricTestCase() { this.vm.outputs.unreadMessagesCount().subscribe { this.unreadMessagesCount.onNext(it) }.addToDisposable(disposables) this.vm.outputs.user().subscribe { this.user.onNext(it) }.addToDisposable(disposables) this.vm.outputs.pledgedProjectsIsVisible().subscribe { this.pledgedProjectsIsVisible.onNext(it) }.addToDisposable(disposables) + this.vm.outputs.pledgedProjectsIndicatorIsVisible().subscribe { this.pledgedProjectsIndicatorIsVisible.onNext(it) }.addToDisposable(disposables) } @Test @@ -188,6 +190,24 @@ class LoggedInViewHolderViewModelTest : KSRobolectricTestCase() { this.pledgedProjectsIsVisible.assertValue(true) } + @Test + fun `when user has project alerts, should emit true`() { + setUpEnvironment(environment()) + val user = UserFactory.user().toBuilder().ppoHasAction(true).build() + this.vm.inputs.configureWith(user) + + this.pledgedProjectsIndicatorIsVisible.assertValue(true) + } + + @Test + fun `when user doesnt have project alerts, should emit false`() { + setUpEnvironment(environment()) + val user = UserFactory.user().toBuilder().ppoHasAction(false).build() + this.vm.inputs.configureWith(user) + + this.pledgedProjectsIndicatorIsVisible.assertValue(false) + } + @After fun clear() { disposables.clear()